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,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
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleFlow
4
+ VERSION = "0.1.0"
5
+ end
@@ -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
+