simple_flow 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd0bd81d08df7f75395ab30c68637a6064c701db6ccd54f98afb05d18ebbf74d
4
- data.tar.gz: c0e288adb2fba5d5101993813bf8df834fd68ee2bd7288c6c2645e3ea56b7225
3
+ metadata.gz: 2773d7d286318dd30f3044935fa28a7cbdae93a5452c87f47bbeba0f0e454fa9
4
+ data.tar.gz: da1af74a15a2ab93758ea4269c2c1b6cdc5376b543249a5f7a12d85edf8948ac
5
5
  SHA512:
6
- metadata.gz: 63462e59c19f7095caa8aa2d9de89eb784f32a2e16e80d13add543fed61add8ed264e3c5aead7af24a41df66a30c1f551c4572df81fb1a3964a9f694cb2965b6
7
- data.tar.gz: 7f5b5ebdd51a20b770c760de90e15ff4ccebba22f99a6af27ff45c831b5da9f0decc7e03bf84b8a51db74af245b614fc561258c38ff1dae61cd7d3a2175e34fc
6
+ metadata.gz: 0a63b15f0514ee9ca2cc930ac958cdb130e14d339cfddacc5b931679fb54b849f85524ab96fdd3fc3aa820c9de396bff2fa3ababa8e17b032f336316f615ea8b
7
+ data.tar.gz: 93966871754ccaa623431caf00a81906daaf2f7ee082fd16c56b1d6d1be96033c500dd7af88fba912882a8f910b9de0a8792b1d80dff4eed1fe674a06b541fe4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-01-15
4
+
5
+ ### Added
6
+ - Optional steps with dynamic activation via `depends_on: :optional`
7
+ - `Result#activate(*step_names)` method for runtime step activation
8
+ - `Result#activated_steps` attribute to track activated steps
9
+ - `Pipeline#optional_steps` attribute returning Set of optional step names
10
+ - Router pattern support for type-based processing paths
11
+ - Soft failure pattern for graceful error handling with cleanup
12
+ - Chained activation allowing optional steps to activate other optional steps
13
+ - Example 13: Optional steps in dynamic DAG demonstration
14
+ - Comprehensive optional steps guide in documentation
15
+
16
+ ### Documentation
17
+ - Added optional steps section to README.md
18
+ - Added optional steps guide (`docs/guides/optional-steps.md`)
19
+ - Updated Result API documentation with `activate` method
20
+ - Updated Pipeline API documentation with `optional_steps` attribute
21
+ - Updated core concepts steps documentation
22
+ - Updated examples README with example 13
23
+
3
24
  ## [0.2.0] - 2025-12-22
4
25
 
5
26
  ### Breaking Changes
data/README.md CHANGED
@@ -242,6 +242,38 @@ result = pipeline.call_parallel(SimpleFlow::Result.new(data))
242
242
 
243
243
  **Note:** For steps with no dependencies, you can use either `depends_on: :none` (more readable) or `depends_on: []`.
244
244
 
245
+ ### Optional Steps (Dynamic DAG)
246
+
247
+ Optional steps are declared with `depends_on: :optional` and only execute when explicitly activated at runtime:
248
+
249
+ ```ruby
250
+ pipeline = SimpleFlow::Pipeline.new do
251
+ step :router, ->(r) {
252
+ case r.value[:type]
253
+ when :pdf then r.continue(r.value).activate(:process_pdf)
254
+ when :image then r.continue(r.value).activate(:process_image)
255
+ else r.continue(r.value).activate(:process_default)
256
+ end
257
+ }, depends_on: :none
258
+
259
+ step :process_pdf, ->(r) { process_pdf(r) }, depends_on: :optional
260
+ step :process_image, ->(r) { process_image(r) }, depends_on: :optional
261
+ step :process_default, ->(r) { process_default(r) }, depends_on: :optional
262
+ end
263
+
264
+ result = pipeline.call_parallel(SimpleFlow::Result.new({ type: :pdf, data: "..." }))
265
+ # Only :router and :process_pdf execute
266
+ ```
267
+
268
+ **Key features:**
269
+ - Declare optional steps with `depends_on: :optional`
270
+ - Activate steps dynamically with `result.activate(:step_name)`
271
+ - Activate multiple steps at once: `result.activate(:step_a, :step_b)`
272
+ - Optional steps can activate other optional steps (chaining)
273
+ - Great for routing, feature flags, and soft error handling
274
+
275
+ **[Learn more →](https://madbomber.github.io/simple_flow/guides/optional-steps/)**
276
+
245
277
  **Execution flow:**
246
278
 
247
279
  ```mermaid
@@ -455,6 +487,7 @@ Check out the `examples/` directory for comprehensive examples:
455
487
  10. `10_concurrency_control.rb` - Per-pipeline concurrency control
456
488
  11. `11_sequential_dependencies.rb` - Sequential step dependencies and halting
457
489
  12. `12_none_constant.rb` - Using reserved dependency symbols `:none` and `:nothing`
490
+ 13. `13_optional_steps_in_dynamic_dag.rb` - Optional steps with dynamic activation
458
491
 
459
492
  ## Requirements
460
493
 
@@ -39,7 +39,7 @@ module SimpleFlow
39
39
  # end
40
40
  #
41
41
  class Pipeline
42
- attr_reader :steps, :middlewares, :named_steps, :step_dependencies, :concurrency, :parallel_groups
42
+ attr_reader :steps, :middlewares, :named_steps, :step_dependencies, :concurrency, :parallel_groups, :optional_steps
43
43
 
44
44
  # Initializes a new Pipeline object. A block can be provided to dynamically configure the pipeline,
45
45
  # allowing the addition of steps and middleware.
@@ -53,6 +53,7 @@ module SimpleFlow
53
53
  @named_steps = {}
54
54
  @step_dependencies = {}
55
55
  @parallel_groups = {}
56
+ @optional_steps = Set.new
56
57
  @concurrency = concurrency
57
58
 
58
59
  validate_concurrency!
@@ -92,7 +93,7 @@ module SimpleFlow
92
93
  callable ||= block
93
94
 
94
95
  # Validate step name
95
- if [:none, :nothing].include?(name)
96
+ if [:none, :nothing, :optional].include?(name)
96
97
  raise ArgumentError, "Step name '#{name}' is reserved. Please use a different name."
97
98
  end
98
99
 
@@ -100,8 +101,16 @@ module SimpleFlow
100
101
 
101
102
  callable = apply_middleware(callable)
102
103
  @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) })
104
+
105
+ # Check if this is an optional step
106
+ if depends_on == :optional
107
+ @optional_steps << name
108
+ @step_dependencies[name] = []
109
+ else
110
+ # Filter out reserved dependency symbols :none and :nothing, and expand parallel group names
111
+ @step_dependencies[name] = expand_dependencies(Array(depends_on).reject { |dep| [:none, :nothing].include?(dep) })
112
+ end
113
+
105
114
  @steps << { name: name, callable: callable, type: :named }
106
115
  else
107
116
  # Unnamed step: step ->(result) { ... } or step { |result| ... }
@@ -312,31 +321,49 @@ module SimpleFlow
312
321
  def execute_with_dependency_graph(result)
313
322
  require_relative 'dependency_graph'
314
323
 
315
- graph = DependencyGraph.new(@step_dependencies)
316
- parallel_groups = graph.parallel_order
317
-
318
324
  current_result = result
319
- step_results = {}
325
+ executed_steps = Set.new
326
+ activated_steps = Set.new
320
327
 
321
- parallel_groups.each do |group|
322
- if group.size == 1
328
+ loop do
329
+ # Build active dependencies: all non-optional steps + activated optional steps
330
+ active_dependencies = build_active_dependencies(activated_steps, executed_steps)
331
+
332
+ # Find the next group of steps that can be executed
333
+ next_group = find_next_executable_group(active_dependencies, executed_steps)
334
+ break if next_group.empty?
335
+
336
+ if next_group.size == 1
323
337
  # Single step, execute sequentially
324
- step_name = group.first
338
+ step_name = next_group.first
325
339
  current_result = @named_steps[step_name].call(current_result)
326
- step_results[step_name] = current_result
340
+ executed_steps << step_name
341
+
342
+ # Process any newly activated steps
343
+ process_activations(current_result, step_name, activated_steps)
344
+
327
345
  return current_result if current_result.respond_to?(:continue?) && !current_result.continue?
328
346
  else
329
347
  # Multiple steps, execute in parallel
330
- callables = group.map { |name| @named_steps[name] }
348
+ callables = next_group.map { |name| @named_steps[name] }
331
349
  results = ParallelExecutor.execute_parallel(callables, current_result, concurrency: @concurrency)
332
350
 
333
351
  # Check if any step halted
334
352
  halted_result = results.find { |r| r.respond_to?(:continue?) && !r.continue? }
335
353
  return halted_result if halted_result
336
354
 
337
- # Merge contexts and errors from all parallel results
355
+ # Mark all steps as executed
356
+ next_group.each { |name| executed_steps << name }
357
+
358
+ # Process activations from all results
359
+ next_group.each_with_index do |name, idx|
360
+ process_activations(results[idx], name, activated_steps)
361
+ end
362
+
363
+ # Merge contexts, errors, and activated_steps from all parallel results
338
364
  merged_context = {}
339
365
  merged_errors = {}
366
+ merged_activated = []
340
367
  results.each do |r|
341
368
  merged_context.merge!(r.context) if r.respond_to?(:context)
342
369
  if r.respond_to?(:errors)
@@ -345,19 +372,16 @@ module SimpleFlow
345
372
  merged_errors[key].concat(messages)
346
373
  end
347
374
  end
375
+ merged_activated.concat(r.activated_steps) if r.respond_to?(:activated_steps)
348
376
  end
349
377
 
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
378
+ # Use the last result's value but with merged context/errors/activated_steps
356
379
  last_result = results.last
357
380
  current_result = Result.new(
358
381
  last_result.value,
359
382
  context: merged_context,
360
- errors: merged_errors
383
+ errors: merged_errors,
384
+ activated_steps: merged_activated.uniq
361
385
  )
362
386
  end
363
387
  end
@@ -365,6 +389,65 @@ module SimpleFlow
365
389
  current_result
366
390
  end
367
391
 
392
+ # Build the active step dependencies, excluding optional steps that haven't been activated
393
+ def build_active_dependencies(activated_steps, executed_steps)
394
+ active_deps = {}
395
+ @step_dependencies.each do |step_name, deps|
396
+ # Skip optional steps that haven't been activated
397
+ next if @optional_steps.include?(step_name) && !activated_steps.include?(step_name)
398
+
399
+ # Check if any dependency is an optional step that hasn't been activated
400
+ has_unactivated_optional_dep = deps.any? do |dep|
401
+ @optional_steps.include?(dep) && !activated_steps.include?(dep)
402
+ end
403
+
404
+ # If this step depends on an optional step that hasn't been activated, skip it
405
+ # (it can only run if/when that optional dependency gets activated)
406
+ next if has_unactivated_optional_dep
407
+
408
+ # Filter dependencies to only include steps that will actually run
409
+ # (non-optional steps and activated optional steps)
410
+ filtered_deps = deps.select do |dep|
411
+ !@optional_steps.include?(dep) || activated_steps.include?(dep)
412
+ end
413
+
414
+ active_deps[step_name] = filtered_deps
415
+ end
416
+ active_deps
417
+ end
418
+
419
+ # Find the next group of steps that can be executed
420
+ def find_next_executable_group(active_dependencies, executed_steps)
421
+ # Find steps whose dependencies have all been executed
422
+ ready_steps = active_dependencies.keys.select do |step_name|
423
+ next false if executed_steps.include?(step_name)
424
+
425
+ deps = active_dependencies[step_name]
426
+ deps.all? { |dep| executed_steps.include?(dep) }
427
+ end
428
+
429
+ ready_steps
430
+ end
431
+
432
+ # Process activated steps from a result, validating each
433
+ def process_activations(result, current_step, activated_steps)
434
+ return unless result.respond_to?(:activated_steps)
435
+
436
+ result.activated_steps.each do |step_name|
437
+ next if activated_steps.include?(step_name) # Idempotent
438
+
439
+ unless @named_steps.key?(step_name)
440
+ raise ArgumentError, "Step :#{current_step} attempted to activate unknown step :#{step_name}"
441
+ end
442
+
443
+ unless @optional_steps.include?(step_name)
444
+ raise ArgumentError, "Step :#{current_step} attempted to activate non-optional step :#{step_name}. Only steps declared with depends_on: :optional can be activated."
445
+ end
446
+
447
+ activated_steps << step_name
448
+ end
449
+ end
450
+
368
451
  def execute_with_explicit_parallelism(result)
369
452
  steps.reduce(result) do |res, step_def|
370
453
  return res if res.respond_to?(:continue?) && !res.continue?
@@ -20,14 +20,19 @@ module SimpleFlow
20
20
  # Errors occurred during the operation.
21
21
  attr_reader :errors
22
22
 
23
+ # Steps that have been activated for dynamic execution.
24
+ attr_reader :activated_steps
25
+
23
26
  # Initializes a new Result instance.
24
27
  # @param value [Object] the outcome of the operation.
25
28
  # @param context [Hash, optional] contextual data related to the operation.
26
29
  # @param errors [Hash, optional] errors occurred during the operation.
27
- def initialize(value, context: {}, errors: {})
30
+ # @param activated_steps [Array<Symbol>, optional] steps activated for dynamic execution.
31
+ def initialize(value, context: {}, errors: {}, activated_steps: [])
28
32
  @value = value
29
33
  @context = context
30
34
  @errors = errors
35
+ @activated_steps = activated_steps
31
36
  @continue = true
32
37
  end
33
38
 
@@ -36,7 +41,7 @@ module SimpleFlow
36
41
  # @param value [Object] the value to store.
37
42
  # @return [Result] a new Result instance with updated context.
38
43
  def with_context(key, value)
39
- result = self.class.new(@value, context: @context.merge(key => value), errors: @errors)
44
+ result = self.class.new(@value, context: @context.merge(key => value), errors: @errors, activated_steps: @activated_steps)
40
45
  result.instance_variable_set(:@continue, @continue)
41
46
  result
42
47
  end
@@ -47,7 +52,7 @@ module SimpleFlow
47
52
  # @param message [String] the error message.
48
53
  # @return [Result] a new Result instance with updated errors.
49
54
  def with_error(key, message)
50
- result = self.class.new(@value, context: @context, errors: @errors.merge(key => [*@errors[key], message]))
55
+ result = self.class.new(@value, context: @context, errors: @errors.merge(key => [*@errors[key], message]), activated_steps: @activated_steps)
51
56
  result.instance_variable_set(:@continue, @continue)
52
57
  result
53
58
  end
@@ -56,7 +61,7 @@ module SimpleFlow
56
61
  # @param new_value [Object, nil] the new value to set, if any.
57
62
  # @return [Result] a new Result instance with continue set to false.
58
63
  def halt(new_value = nil)
59
- result = new_value ? with_value(new_value) : self.class.new(@value, context: @context, errors: @errors)
64
+ result = new_value ? with_value(new_value) : self.class.new(@value, context: @context, errors: @errors, activated_steps: @activated_steps)
60
65
  result.instance_variable_set(:@continue, false)
61
66
  result
62
67
  end
@@ -74,13 +79,23 @@ module SimpleFlow
74
79
  @continue
75
80
  end
76
81
 
82
+ # Activates optional steps for dynamic execution.
83
+ # @param step_names [Symbol, Array<Symbol>] one or more step names to activate.
84
+ # @return [Result] a new Result instance with the steps added to activated_steps.
85
+ def activate(*step_names)
86
+ new_activated = @activated_steps + step_names.flatten
87
+ result = self.class.new(@value, context: @context, errors: @errors, activated_steps: new_activated)
88
+ result.instance_variable_set(:@continue, @continue)
89
+ result
90
+ end
91
+
77
92
  private
78
93
 
79
94
  # Creates a new Result instance with updated value.
80
95
  # @param new_value [Object] the new value for the result.
81
96
  # @return [Result] a new Result instance.
82
97
  def with_value(new_value)
83
- result = self.class.new(new_value, context: @context, errors: @errors)
98
+ result = self.class.new(new_value, context: @context, errors: @errors, activated_steps: @activated_steps)
84
99
  result.instance_variable_set(:@continue, @continue)
85
100
  result
86
101
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleFlow
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_flow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -54,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0'
56
56
  requirements: []
57
- rubygems_version: 4.0.2
57
+ rubygems_version: 4.0.4
58
58
  specification_version: 4
59
59
  summary: A lightweight, modular Ruby framework for building composable data processing
60
60
  pipelines