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,405 @@
|
|
|
1
|
+
module SimpleFlow
|
|
2
|
+
##
|
|
3
|
+
# The Pipeline class facilitates the creation and execution of a sequence of steps (or operations),
|
|
4
|
+
# with the possibility of inserting middleware to modify or handle the processing in a flexible way.
|
|
5
|
+
# This allows for a clean and modular design where components can be easily added, removed, or replaced
|
|
6
|
+
# without affecting the overall logic flow. It is particularly useful for scenarios where a set of operations
|
|
7
|
+
# needs to be performed in a specific order, and you want to maintain the capability to inject additional
|
|
8
|
+
# behavior (like logging, authorization, or input/output transformations) at any point in this sequence.
|
|
9
|
+
#
|
|
10
|
+
# Example Usage:
|
|
11
|
+
# pipeline = SimpleFlow::Pipeline.new do
|
|
12
|
+
# use_middleware SomeMiddlewareClass, option: value
|
|
13
|
+
# step ->(input) { do_something_with(input) }
|
|
14
|
+
# step AnotherCallableObject
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# result = pipeline.call(initial_data)
|
|
18
|
+
#
|
|
19
|
+
# Parallel Execution with Named Steps:
|
|
20
|
+
# pipeline = SimpleFlow::Pipeline.new do
|
|
21
|
+
# step :fetch_user, ->(result) { ... }, depends_on: :none
|
|
22
|
+
# step :fetch_orders, ->(result) { ... }, depends_on: [:fetch_user]
|
|
23
|
+
# step :fetch_products, ->(result) { ... }, depends_on: [:fetch_user]
|
|
24
|
+
# step :calculate, ->(result) { ... }, depends_on: [:fetch_orders, :fetch_products]
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# result = pipeline.call_parallel(initial_data) # Auto-detects parallelism
|
|
28
|
+
#
|
|
29
|
+
# Note: You can use either depends_on: [] or depends_on: :none for clarity
|
|
30
|
+
#
|
|
31
|
+
# Explicit Parallel Blocks:
|
|
32
|
+
# pipeline = SimpleFlow::Pipeline.new do
|
|
33
|
+
# step ->(result) { ... }
|
|
34
|
+
# parallel do
|
|
35
|
+
# step ->(result) { ... }
|
|
36
|
+
# step ->(result) { ... }
|
|
37
|
+
# end
|
|
38
|
+
# step ->(result) { ... }
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
class Pipeline
|
|
42
|
+
attr_reader :steps, :middlewares, :named_steps, :step_dependencies, :concurrency, :parallel_groups
|
|
43
|
+
|
|
44
|
+
# Initializes a new Pipeline object. A block can be provided to dynamically configure the pipeline,
|
|
45
|
+
# allowing the addition of steps and middleware.
|
|
46
|
+
# @param concurrency [Symbol] concurrency model to use (:auto, :threads, :async)
|
|
47
|
+
# - :auto (default) - uses async if available, falls back to threads
|
|
48
|
+
# - :threads - always uses Ruby threads
|
|
49
|
+
# - :async - uses async gem (raises error if not available)
|
|
50
|
+
def initialize(concurrency: :auto, &config)
|
|
51
|
+
@steps = []
|
|
52
|
+
@middlewares = []
|
|
53
|
+
@named_steps = {}
|
|
54
|
+
@step_dependencies = {}
|
|
55
|
+
@parallel_groups = {}
|
|
56
|
+
@concurrency = concurrency
|
|
57
|
+
|
|
58
|
+
validate_concurrency!
|
|
59
|
+
|
|
60
|
+
instance_eval(&config) if block_given?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registers a middleware to be applied to each step. Middlewares can be provided as Proc objects or any
|
|
64
|
+
# object that responds to `.new` with the callable to be wrapped and options hash.
|
|
65
|
+
# @param [Proc, Class] middleware the middleware to be used
|
|
66
|
+
# @param [Hash] options any options to be passed to the middleware upon initialization
|
|
67
|
+
def use_middleware(middleware, options = {})
|
|
68
|
+
@middlewares << [middleware, options]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Adds a step to the pipeline. Supports both named and unnamed steps.
|
|
72
|
+
#
|
|
73
|
+
# Named steps with dependencies (for automatic parallel detection):
|
|
74
|
+
# step :fetch_user, ->(result) { ... }, depends_on: []
|
|
75
|
+
# step :process_data, ->(result) { ... }, depends_on: [:fetch_user]
|
|
76
|
+
#
|
|
77
|
+
# Unnamed steps (traditional usage):
|
|
78
|
+
# step ->(result) { ... }
|
|
79
|
+
# step { |result| ... }
|
|
80
|
+
#
|
|
81
|
+
# @param [Symbol, Proc, Object] name_or_callable step name (Symbol) or callable object
|
|
82
|
+
# @param [Proc, Object] callable an object responding to call (if first param is a name)
|
|
83
|
+
# @param [Hash] options options including :depends_on for dependency declaration
|
|
84
|
+
# @param block [Block] a block to use as the step if no callable is provided
|
|
85
|
+
# @raise [ArgumentError] if neither a callable nor block is given, or if the provided object does not respond to call
|
|
86
|
+
# @return [self] so that calls can be chained
|
|
87
|
+
def step(name_or_callable = nil, callable = nil, depends_on: [], &block)
|
|
88
|
+
# Handle different calling patterns
|
|
89
|
+
if name_or_callable.is_a?(Symbol)
|
|
90
|
+
# Named step: step :name, ->(result) { ... }, depends_on: [...]
|
|
91
|
+
name = name_or_callable
|
|
92
|
+
callable ||= block
|
|
93
|
+
|
|
94
|
+
# Validate step name
|
|
95
|
+
if [:none, :nothing].include?(name)
|
|
96
|
+
raise ArgumentError, "Step name '#{name}' is reserved. Please use a different name."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "Step must respond to #call" unless callable.respond_to?(:call)
|
|
100
|
+
|
|
101
|
+
callable = apply_middleware(callable)
|
|
102
|
+
@named_steps[name] = callable
|
|
103
|
+
# Filter out reserved dependency symbols :none and :nothing, and expand parallel group names
|
|
104
|
+
@step_dependencies[name] = expand_dependencies(Array(depends_on).reject { |dep| [:none, :nothing].include?(dep) })
|
|
105
|
+
@steps << { name: name, callable: callable, type: :named }
|
|
106
|
+
else
|
|
107
|
+
# Unnamed step: step ->(result) { ... } or step { |result| ... }
|
|
108
|
+
callable = name_or_callable || block
|
|
109
|
+
raise ArgumentError, "Step must respond to #call" unless callable.respond_to?(:call)
|
|
110
|
+
|
|
111
|
+
callable = apply_middleware(callable)
|
|
112
|
+
@steps << { callable: callable, type: :unnamed }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Defines a parallel execution block. Steps within this block will execute concurrently.
|
|
119
|
+
# @param name [Symbol, nil] optional name for the parallel group
|
|
120
|
+
# @param depends_on [Symbol, Array] dependencies for this parallel group
|
|
121
|
+
# @param block [Block] block containing step definitions
|
|
122
|
+
# @return [self]
|
|
123
|
+
# @example Named parallel group with dependencies
|
|
124
|
+
# parallel :fetch_data, depends_on: :validate do
|
|
125
|
+
# step :fetch_orders, ->(result) { ... }
|
|
126
|
+
# step :fetch_products, ->(result) { ... }
|
|
127
|
+
# end
|
|
128
|
+
# step :process, ->(result) { ... }, depends_on: :fetch_data
|
|
129
|
+
def parallel(name = nil, depends_on: :none, &block)
|
|
130
|
+
# Validate name if provided
|
|
131
|
+
if name && [:none, :nothing].include?(name)
|
|
132
|
+
raise ArgumentError, "Parallel group name '#{name}' is reserved. Please use a different name."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Filter and expand dependencies
|
|
136
|
+
filtered_deps = expand_dependencies(Array(depends_on).reject { |dep| [:none, :nothing].include?(dep) })
|
|
137
|
+
|
|
138
|
+
# Create and evaluate the parallel block
|
|
139
|
+
group = ParallelBlock.new(self)
|
|
140
|
+
group.instance_eval(&block)
|
|
141
|
+
|
|
142
|
+
if name
|
|
143
|
+
# Named parallel group - track it for dependency resolution
|
|
144
|
+
step_names = group.steps.map { |s| s[:name] }.compact
|
|
145
|
+
@parallel_groups[name] = {
|
|
146
|
+
steps: step_names,
|
|
147
|
+
dependencies: filtered_deps
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Add dependencies from the parallel group to its contained steps
|
|
151
|
+
step_names.each do |step_name|
|
|
152
|
+
@step_dependencies[step_name] = filtered_deps
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@steps << { steps: group.steps, type: :parallel, name: name }
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Internal: Applies registered middlewares to a callable.
|
|
161
|
+
# @param [Proc, Object] callable the target callable to wrap with middleware
|
|
162
|
+
# @return [Object] the callable wrapped with all registered middleware
|
|
163
|
+
def apply_middleware(callable)
|
|
164
|
+
@middlewares.reverse_each do |middleware, options|
|
|
165
|
+
if middleware.is_a?(Proc)
|
|
166
|
+
callable = middleware.call(callable)
|
|
167
|
+
else
|
|
168
|
+
callable = middleware.new(callable, **options)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
callable
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Executes the pipeline with a given initial result. Each step is called in order, and the result of a step
|
|
175
|
+
# is passed to the next. Execution can be short-circuited by a step returning an object that does not
|
|
176
|
+
# satisfy a `continue?` condition.
|
|
177
|
+
# @param result [Object] the initial data/input to be passed through the pipeline
|
|
178
|
+
# @return [Object] the result of executing the pipeline
|
|
179
|
+
def call(result)
|
|
180
|
+
steps.reduce(result) do |res, step_def|
|
|
181
|
+
return res if res.respond_to?(:continue?) && !res.continue?
|
|
182
|
+
|
|
183
|
+
case step_def
|
|
184
|
+
when Hash
|
|
185
|
+
execute_step_def(step_def, res)
|
|
186
|
+
else
|
|
187
|
+
# Backward compatibility with old format
|
|
188
|
+
step_def.call(res)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Executes the pipeline with parallel execution where possible.
|
|
194
|
+
# For named steps with dependencies, automatically detects which steps can run in parallel.
|
|
195
|
+
# For explicit parallel blocks, executes them concurrently.
|
|
196
|
+
# @param result [Object] the initial data/input to be passed through the pipeline
|
|
197
|
+
# @param strategy [Symbol] :auto (automatic detection) or :explicit (only explicit parallel blocks)
|
|
198
|
+
# @return [Object] the result of executing the pipeline
|
|
199
|
+
def call_parallel(result, strategy: :auto)
|
|
200
|
+
if strategy == :auto && has_named_steps?
|
|
201
|
+
execute_with_dependency_graph(result)
|
|
202
|
+
else
|
|
203
|
+
execute_with_explicit_parallelism(result)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Check if async gem is available for parallel execution
|
|
208
|
+
# @return [Boolean]
|
|
209
|
+
def async_available?
|
|
210
|
+
ParallelExecutor.async_available?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Get the dependency graph for this pipeline
|
|
214
|
+
# @return [DependencyGraph, nil] dependency graph if pipeline has named steps
|
|
215
|
+
def dependency_graph
|
|
216
|
+
return nil unless has_named_steps?
|
|
217
|
+
DependencyGraph.new(@step_dependencies)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Create a visualizer for this pipeline's dependency graph
|
|
221
|
+
# @return [DependencyGraphVisualizer, nil] visualizer if pipeline has named steps
|
|
222
|
+
def visualize
|
|
223
|
+
graph = dependency_graph
|
|
224
|
+
return nil unless graph
|
|
225
|
+
DependencyGraphVisualizer.new(graph)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Print ASCII visualization of the pipeline's dependency graph
|
|
229
|
+
# @param show_groups [Boolean] whether to show parallel execution groups
|
|
230
|
+
# @return [String, nil] ASCII visualization or nil if no named steps
|
|
231
|
+
def visualize_ascii(show_groups: true)
|
|
232
|
+
visualizer = visualize
|
|
233
|
+
return nil unless visualizer
|
|
234
|
+
visualizer.to_ascii(show_groups: show_groups)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Export pipeline visualization to DOT format
|
|
238
|
+
# @param include_groups [Boolean] whether to color-code parallel groups
|
|
239
|
+
# @param orientation [String] graph orientation: 'TB' or 'LR'
|
|
240
|
+
# @return [String, nil] DOT format or nil if no named steps
|
|
241
|
+
def visualize_dot(include_groups: true, orientation: 'TB')
|
|
242
|
+
visualizer = visualize
|
|
243
|
+
return nil unless visualizer
|
|
244
|
+
visualizer.to_dot(include_groups: include_groups, orientation: orientation)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Export pipeline visualization to Mermaid format
|
|
248
|
+
# @return [String, nil] Mermaid format or nil if no named steps
|
|
249
|
+
def visualize_mermaid
|
|
250
|
+
visualizer = visualize
|
|
251
|
+
return nil unless visualizer
|
|
252
|
+
visualizer.to_mermaid
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Get execution plan for this pipeline
|
|
256
|
+
# @return [String, nil] execution plan or nil if no named steps
|
|
257
|
+
def execution_plan
|
|
258
|
+
visualizer = visualize
|
|
259
|
+
return nil unless visualizer
|
|
260
|
+
visualizer.to_execution_plan
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
# Expands parallel group names in dependencies to all steps in those groups
|
|
266
|
+
# @param deps [Array<Symbol>] array of dependency symbols
|
|
267
|
+
# @return [Array<Symbol>] expanded array with parallel groups replaced by their steps
|
|
268
|
+
def expand_dependencies(deps)
|
|
269
|
+
deps.flat_map do |dep|
|
|
270
|
+
if @parallel_groups.key?(dep)
|
|
271
|
+
# This is a parallel group name - expand to all steps in the group
|
|
272
|
+
@parallel_groups[dep][:steps]
|
|
273
|
+
else
|
|
274
|
+
# Regular step name
|
|
275
|
+
dep
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def validate_concurrency!
|
|
281
|
+
valid_options = [:auto, :threads, :async]
|
|
282
|
+
unless valid_options.include?(@concurrency)
|
|
283
|
+
raise ArgumentError, "Invalid concurrency option: #{@concurrency.inspect}. Valid options: #{valid_options.inspect}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
if @concurrency == :async && !ParallelExecutor.async_available?
|
|
287
|
+
raise ArgumentError, "Concurrency set to :async but async gem is not available. Install with: gem 'async', '~> 2.0'"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def has_named_steps?
|
|
292
|
+
@named_steps.any?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def execute_step_def(step_def, result)
|
|
296
|
+
case step_def[:type]
|
|
297
|
+
when :named, :unnamed
|
|
298
|
+
step_def[:callable].call(result)
|
|
299
|
+
when :parallel
|
|
300
|
+
execute_parallel_group(step_def[:steps], result)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def execute_parallel_group(steps, result)
|
|
305
|
+
callables = steps.map { |s| s[:callable] }
|
|
306
|
+
results = ParallelExecutor.execute_parallel(callables, result, concurrency: @concurrency)
|
|
307
|
+
|
|
308
|
+
# Return the first halted result, or the last result if all continued
|
|
309
|
+
results.find { |r| r.respond_to?(:continue?) && !r.continue? } || results.last
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def execute_with_dependency_graph(result)
|
|
313
|
+
require_relative 'dependency_graph'
|
|
314
|
+
|
|
315
|
+
graph = DependencyGraph.new(@step_dependencies)
|
|
316
|
+
parallel_groups = graph.parallel_order
|
|
317
|
+
|
|
318
|
+
current_result = result
|
|
319
|
+
step_results = {}
|
|
320
|
+
|
|
321
|
+
parallel_groups.each do |group|
|
|
322
|
+
if group.size == 1
|
|
323
|
+
# Single step, execute sequentially
|
|
324
|
+
step_name = group.first
|
|
325
|
+
current_result = @named_steps[step_name].call(current_result)
|
|
326
|
+
step_results[step_name] = current_result
|
|
327
|
+
return current_result if current_result.respond_to?(:continue?) && !current_result.continue?
|
|
328
|
+
else
|
|
329
|
+
# Multiple steps, execute in parallel
|
|
330
|
+
callables = group.map { |name| @named_steps[name] }
|
|
331
|
+
results = ParallelExecutor.execute_parallel(callables, current_result, concurrency: @concurrency)
|
|
332
|
+
|
|
333
|
+
# Check if any step halted
|
|
334
|
+
halted_result = results.find { |r| r.respond_to?(:continue?) && !r.continue? }
|
|
335
|
+
return halted_result if halted_result
|
|
336
|
+
|
|
337
|
+
# Merge contexts and errors from all parallel results
|
|
338
|
+
merged_context = {}
|
|
339
|
+
merged_errors = {}
|
|
340
|
+
results.each do |r|
|
|
341
|
+
merged_context.merge!(r.context) if r.respond_to?(:context)
|
|
342
|
+
if r.respond_to?(:errors)
|
|
343
|
+
r.errors.each do |key, messages|
|
|
344
|
+
merged_errors[key] ||= []
|
|
345
|
+
merged_errors[key].concat(messages)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Store results and create merged result
|
|
351
|
+
group.each_with_index do |name, idx|
|
|
352
|
+
step_results[name] = results[idx]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Use the last result's value but with merged context/errors
|
|
356
|
+
last_result = results.last
|
|
357
|
+
current_result = Result.new(
|
|
358
|
+
last_result.value,
|
|
359
|
+
context: merged_context,
|
|
360
|
+
errors: merged_errors
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
current_result
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def execute_with_explicit_parallelism(result)
|
|
369
|
+
steps.reduce(result) do |res, step_def|
|
|
370
|
+
return res if res.respond_to?(:continue?) && !res.continue?
|
|
371
|
+
execute_step_def(step_def, res)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Helper class for building parallel blocks
|
|
376
|
+
class ParallelBlock
|
|
377
|
+
attr_reader :steps
|
|
378
|
+
|
|
379
|
+
def initialize(pipeline)
|
|
380
|
+
@pipeline = pipeline
|
|
381
|
+
@steps = []
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def step(name_or_callable = nil, callable = nil, depends_on: [], &block)
|
|
385
|
+
if name_or_callable.is_a?(Symbol)
|
|
386
|
+
name = name_or_callable
|
|
387
|
+
callable ||= block
|
|
388
|
+
raise ArgumentError, "Step must respond to #call" unless callable.respond_to?(:call)
|
|
389
|
+
callable = @pipeline.send(:apply_middleware, callable)
|
|
390
|
+
# Register the step in the pipeline's named_steps
|
|
391
|
+
@pipeline.instance_variable_get(:@named_steps)[name] = callable
|
|
392
|
+
@steps << { name: name, callable: callable, type: :named }
|
|
393
|
+
else
|
|
394
|
+
callable = name_or_callable || block
|
|
395
|
+
raise ArgumentError, "Step must respond to #call" unless callable.respond_to?(:call)
|
|
396
|
+
callable = @pipeline.send(:apply_middleware, callable)
|
|
397
|
+
@steps << { callable: callable, type: :unnamed }
|
|
398
|
+
end
|
|
399
|
+
self
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module SimpleFlow
|
|
2
|
+
##
|
|
3
|
+
# This class represents the result of an operation within a simple flow system.
|
|
4
|
+
#
|
|
5
|
+
# It encapsulates the operation's outcome (value), contextual data (context),
|
|
6
|
+
# and any errors occurred during the operation (errors). Its primary purpose
|
|
7
|
+
# is to facilitate flow control and error handling in a clean and predictable
|
|
8
|
+
# manner. The class provides mechanisms to update context and errors, halt
|
|
9
|
+
# the flow, and conditionally continue based on the operation state. This
|
|
10
|
+
# promotes creating a chainable, fluent interface for managing operation
|
|
11
|
+
# results in complex processes or workflows.
|
|
12
|
+
#
|
|
13
|
+
class Result
|
|
14
|
+
# The outcome of the operation.
|
|
15
|
+
attr_reader :value
|
|
16
|
+
|
|
17
|
+
# Contextual data related to the operation.
|
|
18
|
+
attr_reader :context
|
|
19
|
+
|
|
20
|
+
# Errors occurred during the operation.
|
|
21
|
+
attr_reader :errors
|
|
22
|
+
|
|
23
|
+
# Initializes a new Result instance.
|
|
24
|
+
# @param value [Object] the outcome of the operation.
|
|
25
|
+
# @param context [Hash, optional] contextual data related to the operation.
|
|
26
|
+
# @param errors [Hash, optional] errors occurred during the operation.
|
|
27
|
+
def initialize(value, context: {}, errors: {})
|
|
28
|
+
@value = value
|
|
29
|
+
@context = context
|
|
30
|
+
@errors = errors
|
|
31
|
+
@continue = true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Adds or updates context to the result.
|
|
35
|
+
# @param key [Symbol] the key to store the context under.
|
|
36
|
+
# @param value [Object] the value to store.
|
|
37
|
+
# @return [Result] a new Result instance with updated context.
|
|
38
|
+
def with_context(key, value)
|
|
39
|
+
result = self.class.new(@value, context: @context.merge(key => value), errors: @errors)
|
|
40
|
+
result.instance_variable_set(:@continue, @continue)
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Adds an error message under a specific key.
|
|
45
|
+
# If the key already exists, it appends the message to the existing errors.
|
|
46
|
+
# @param key [Symbol] the key under which the error should be stored.
|
|
47
|
+
# @param message [String] the error message.
|
|
48
|
+
# @return [Result] a new Result instance with updated errors.
|
|
49
|
+
def with_error(key, message)
|
|
50
|
+
result = self.class.new(@value, context: @context, errors: @errors.merge(key => [*@errors[key], message]))
|
|
51
|
+
result.instance_variable_set(:@continue, @continue)
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Halts the flow, optionally updating the result's value.
|
|
56
|
+
# @param new_value [Object, nil] the new value to set, if any.
|
|
57
|
+
# @return [Result] a new Result instance with continue set to false.
|
|
58
|
+
def halt(new_value = nil)
|
|
59
|
+
result = new_value ? with_value(new_value) : self.class.new(@value, context: @context, errors: @errors)
|
|
60
|
+
result.instance_variable_set(:@continue, false)
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Continues the flow, updating the result's value.
|
|
65
|
+
# @param new_value [Object] the new value to set.
|
|
66
|
+
# @return [Result] a new Result instance with the new value.
|
|
67
|
+
def continue(new_value)
|
|
68
|
+
with_value(new_value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Checks if the operation should continue.
|
|
72
|
+
# @return [Boolean] true if the operation should continue, else false.
|
|
73
|
+
def continue?
|
|
74
|
+
@continue
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Creates a new Result instance with updated value.
|
|
80
|
+
# @param new_value [Object] the new value for the result.
|
|
81
|
+
# @return [Result] a new Result instance.
|
|
82
|
+
def with_value(new_value)
|
|
83
|
+
result = self.class.new(new_value, context: @context, errors: @errors)
|
|
84
|
+
result.instance_variable_set(:@continue, @continue)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require 'delegate'
|
|
2
|
+
|
|
3
|
+
# This Ruby module is called SimpleFlow and it provides a simple workflow management utility.
|
|
4
|
+
# The key component demonstrated here is the StepTracker class, which is used to track the
|
|
5
|
+
# execution of steps within a workflow. This class utilizes the decorator pattern by inheriting
|
|
6
|
+
# from SimpleDelegator, allowing it to wrap around any object that responds to #call, and
|
|
7
|
+
# enhancing its behavior without modifying the original object's class.
|
|
8
|
+
module SimpleFlow
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# The StepTracker class serves as a wrapper around any callable object, typically representing
|
|
12
|
+
# a step in a workflow. Its primary purpose is to execute the wrapped object's call method
|
|
13
|
+
# and then decide what to do based on the outcome. If the result of the call indicates that
|
|
14
|
+
# the process can continue, it simply returns the result. However, if the process should halt,
|
|
15
|
+
# it enriches the result with additional context before returning it.
|
|
16
|
+
#
|
|
17
|
+
# This enables a mechanism for both executing steps in a workflow and conditionally handling
|
|
18
|
+
# situations where a step decides the flow should not continue. The enriched context can then
|
|
19
|
+
# be used downstream to understand why the flow was halted, and potentially take corrective action.
|
|
20
|
+
#
|
|
21
|
+
# Example usage:
|
|
22
|
+
#
|
|
23
|
+
# class ExampleStep
|
|
24
|
+
# def call(result)
|
|
25
|
+
# result.do_something
|
|
26
|
+
# if result.success?
|
|
27
|
+
# result.with_continue(true)
|
|
28
|
+
# else
|
|
29
|
+
# result.with_continue(false)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# result = SomeResult.new
|
|
35
|
+
# wrapped_step = SimpleFlow::StepTracker.new(ExampleStep.new)
|
|
36
|
+
# final_result = wrapped_step.call(result)
|
|
37
|
+
#
|
|
38
|
+
# if final_result.continue
|
|
39
|
+
# # proceed with workflow
|
|
40
|
+
# else
|
|
41
|
+
# # handle halted workflow
|
|
42
|
+
# end
|
|
43
|
+
class StepTracker < SimpleDelegator
|
|
44
|
+
|
|
45
|
+
# Calls the wrapped object's call method with the given result. Depending on the outcome
|
|
46
|
+
# of this call, it either returns the result as-is (to continue the workflow) or enriches
|
|
47
|
+
# the result with context indicating that this particular step is where the workflow was
|
|
48
|
+
# halted.
|
|
49
|
+
#
|
|
50
|
+
# @param result [Object] The result object that is being passed through the workflow steps.
|
|
51
|
+
# @return [Object] The modified result object, potentially enriched with additional context.
|
|
52
|
+
def call(result)
|
|
53
|
+
result = __getobj__.call(result)
|
|
54
|
+
result.continue? ? result : result.with_context(:halted_step, __getobj__)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
data/lib/simple_flow.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SimpleFlow is a modular, configurable processing framework designed for constructing and
|
|
3
|
+
# managing sequences of operations in a streamlined and efficient manner. It allows for the easy
|
|
4
|
+
# integration of middleware components to augment functionality, such as logging and
|
|
5
|
+
# instrumentation, ensuring that actions within the pipeline are executed seamlessly. By
|
|
6
|
+
# defining steps as callable objects, SimpleFlow facilitates the customized processing of data,
|
|
7
|
+
# offering granular control over the flow of execution and enabling conditional continuation
|
|
8
|
+
# based on the outcome of each step. This approach makes SimpleFlow ideal for complex workflows
|
|
9
|
+
# where the orchestration of tasks, error handling, and context management are crucial.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
require 'delegate'
|
|
13
|
+
require 'logger'
|
|
14
|
+
|
|
15
|
+
require_relative 'simple_flow/version'
|
|
16
|
+
require_relative 'simple_flow/result'
|
|
17
|
+
require_relative 'simple_flow/middleware'
|
|
18
|
+
require_relative 'simple_flow/dependency_graph'
|
|
19
|
+
require_relative 'simple_flow/dependency_graph_visualizer'
|
|
20
|
+
require_relative 'simple_flow/parallel_executor'
|
|
21
|
+
require_relative 'simple_flow/pipeline'
|
|
22
|
+
|
|
23
|
+
module SimpleFlow
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
__END__
|
|
27
|
+
|
|
28
|
+
require_relative './simple_flow'
|
|
29
|
+
|
|
30
|
+
# Usage example
|
|
31
|
+
pipeline = SimpleFlow::Pipeline.new do
|
|
32
|
+
use_middleware SimpleFlow::MiddleWare::Instrumentation, api_key: '1234'
|
|
33
|
+
use_middleware SimpleFlow::MiddleWare::Logging
|
|
34
|
+
step ->(result) { puts "Processing: #{result.value}"; result }
|
|
35
|
+
step ->(result) { result.continue(result.value + 1) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
initial_result = SimpleFlow::Result.new(0)
|
|
39
|
+
result = pipeline.call(initial_result)
|
|
40
|
+
puts "Final Result: #{result.value}"
|
|
41
|
+
|