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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +33 -0
- data/lib/simple_flow/pipeline.rb +104 -21
- data/lib/simple_flow/result.rb +20 -5
- data/lib/simple_flow/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2773d7d286318dd30f3044935fa28a7cbdae93a5452c87f47bbeba0f0e454fa9
|
|
4
|
+
data.tar.gz: da1af74a15a2ab93758ea4269c2c1b6cdc5376b543249a5f7a12d85edf8948ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/simple_flow/pipeline.rb
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
325
|
+
executed_steps = Set.new
|
|
326
|
+
activated_steps = Set.new
|
|
320
327
|
|
|
321
|
-
|
|
322
|
-
|
|
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 =
|
|
338
|
+
step_name = next_group.first
|
|
325
339
|
current_result = @named_steps[step_name].call(current_result)
|
|
326
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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?
|
data/lib/simple_flow/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/simple_flow/version.rb
CHANGED
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.
|
|
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.
|
|
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
|