cmdx 1.21.0 → 2.0.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 +118 -1
- data/README.md +37 -24
- data/lib/cmdx/.DS_Store +0 -0
- data/lib/cmdx/callbacks.rb +179 -0
- data/lib/cmdx/chain.rb +78 -175
- data/lib/cmdx/coercions/array.rb +19 -33
- data/lib/cmdx/coercions/big_decimal.rb +12 -29
- data/lib/cmdx/coercions/boolean.rb +25 -45
- data/lib/cmdx/coercions/coerce.rb +32 -0
- data/lib/cmdx/coercions/complex.rb +12 -27
- data/lib/cmdx/coercions/date.rb +29 -33
- data/lib/cmdx/coercions/date_time.rb +29 -33
- data/lib/cmdx/coercions/float.rb +8 -29
- data/lib/cmdx/coercions/hash.rb +17 -43
- data/lib/cmdx/coercions/integer.rb +8 -32
- data/lib/cmdx/coercions/rational.rb +12 -33
- data/lib/cmdx/coercions/string.rb +6 -24
- data/lib/cmdx/coercions/symbol.rb +12 -26
- data/lib/cmdx/coercions/time.rb +31 -35
- data/lib/cmdx/coercions.rb +174 -0
- data/lib/cmdx/configuration.rb +45 -237
- data/lib/cmdx/context.rb +264 -243
- data/lib/cmdx/deprecation.rb +67 -0
- data/lib/cmdx/deprecators/error.rb +22 -0
- data/lib/cmdx/deprecators/log.rb +22 -0
- data/lib/cmdx/deprecators/warn.rb +21 -0
- data/lib/cmdx/deprecators.rb +101 -0
- data/lib/cmdx/errors.rb +145 -79
- data/lib/cmdx/executors/fiber.rb +42 -0
- data/lib/cmdx/executors/thread.rb +36 -0
- data/lib/cmdx/executors.rb +95 -0
- data/lib/cmdx/fault.rb +85 -78
- data/lib/cmdx/i18n_proxy.rb +104 -0
- data/lib/cmdx/input.rb +294 -0
- data/lib/cmdx/inputs.rb +218 -0
- data/lib/cmdx/log_formatters/json.rb +9 -20
- data/lib/cmdx/log_formatters/key_value.rb +10 -21
- data/lib/cmdx/log_formatters/line.rb +7 -19
- data/lib/cmdx/log_formatters/logstash.rb +8 -21
- data/lib/cmdx/log_formatters/raw.rb +8 -20
- data/lib/cmdx/logger_proxy.rb +30 -0
- data/lib/cmdx/mergers/deep_merge.rb +23 -0
- data/lib/cmdx/mergers/last_write_wins.rb +23 -0
- data/lib/cmdx/mergers/no_merge.rb +20 -0
- data/lib/cmdx/mergers.rb +95 -0
- data/lib/cmdx/middlewares.rb +128 -0
- data/lib/cmdx/output.rb +115 -0
- data/lib/cmdx/outputs.rb +66 -0
- data/lib/cmdx/pipeline.rb +144 -131
- data/lib/cmdx/railtie.rb +10 -36
- data/lib/cmdx/result.rb +247 -524
- data/lib/cmdx/retriers/bounded_random.rb +24 -0
- data/lib/cmdx/retriers/decorrelated_jitter.rb +28 -0
- data/lib/cmdx/retriers/exponential.rb +23 -0
- data/lib/cmdx/retriers/fibonacci.rb +39 -0
- data/lib/cmdx/retriers/full_random.rb +23 -0
- data/lib/cmdx/retriers/half_random.rb +24 -0
- data/lib/cmdx/retriers/linear.rb +23 -0
- data/lib/cmdx/retriers.rb +106 -0
- data/lib/cmdx/retry.rb +117 -138
- data/lib/cmdx/runtime.rb +251 -0
- data/lib/cmdx/settings.rb +68 -200
- data/lib/cmdx/signal.rb +165 -0
- data/lib/cmdx/task.rb +443 -343
- data/lib/cmdx/telemetry.rb +108 -0
- data/lib/cmdx/util.rb +73 -0
- data/lib/cmdx/validators/absence.rb +10 -39
- data/lib/cmdx/validators/exclusion.rb +33 -52
- data/lib/cmdx/validators/format.rb +19 -49
- data/lib/cmdx/validators/inclusion.rb +33 -54
- data/lib/cmdx/validators/length.rb +125 -127
- data/lib/cmdx/validators/numeric.rb +123 -123
- data/lib/cmdx/validators/presence.rb +10 -39
- data/lib/cmdx/validators/validate.rb +31 -0
- data/lib/cmdx/validators.rb +161 -0
- data/lib/cmdx/version.rb +2 -4
- data/lib/cmdx/workflow.rb +71 -96
- data/lib/cmdx.rb +111 -42
- data/lib/generators/cmdx/install_generator.rb +7 -17
- data/lib/generators/cmdx/task_generator.rb +12 -29
- data/lib/generators/cmdx/templates/install.rb +120 -48
- data/lib/generators/cmdx/templates/task.rb.tt +1 -1
- data/lib/generators/cmdx/templates/workflow.rb.tt +1 -2
- data/lib/generators/cmdx/workflow_generator.rb +12 -29
- data/lib/locales/en.yml +8 -7
- data/mkdocs.yml +25 -23
- metadata +39 -138
- data/lib/cmdx/attribute.rb +0 -440
- data/lib/cmdx/attribute_registry.rb +0 -185
- data/lib/cmdx/attribute_value.rb +0 -252
- data/lib/cmdx/callback_registry.rb +0 -169
- data/lib/cmdx/coercion_registry.rb +0 -138
- data/lib/cmdx/deprecator.rb +0 -77
- data/lib/cmdx/exception.rb +0 -46
- data/lib/cmdx/executor.rb +0 -378
- data/lib/cmdx/identifier.rb +0 -30
- data/lib/cmdx/locale.rb +0 -78
- data/lib/cmdx/middleware_registry.rb +0 -148
- data/lib/cmdx/middlewares/correlate.rb +0 -140
- data/lib/cmdx/middlewares/runtime.rb +0 -77
- data/lib/cmdx/middlewares/timeout.rb +0 -78
- data/lib/cmdx/parallelizer.rb +0 -100
- data/lib/cmdx/utils/call.rb +0 -53
- data/lib/cmdx/utils/condition.rb +0 -71
- data/lib/cmdx/utils/format.rb +0 -82
- data/lib/cmdx/utils/normalize.rb +0 -52
- data/lib/cmdx/utils/wrap.rb +0 -38
- data/lib/cmdx/validator_registry.rb +0 -143
- data/lib/generators/cmdx/locale_generator.rb +0 -39
- data/lib/locales/af.yml +0 -55
- data/lib/locales/ar.yml +0 -55
- data/lib/locales/az.yml +0 -55
- data/lib/locales/be.yml +0 -55
- data/lib/locales/bg.yml +0 -55
- data/lib/locales/bn.yml +0 -55
- data/lib/locales/bs.yml +0 -55
- data/lib/locales/ca.yml +0 -55
- data/lib/locales/cnr.yml +0 -55
- data/lib/locales/cs.yml +0 -55
- data/lib/locales/cy.yml +0 -55
- data/lib/locales/da.yml +0 -55
- data/lib/locales/de.yml +0 -55
- data/lib/locales/dz.yml +0 -55
- data/lib/locales/el.yml +0 -55
- data/lib/locales/eo.yml +0 -55
- data/lib/locales/es.yml +0 -55
- data/lib/locales/et.yml +0 -55
- data/lib/locales/eu.yml +0 -55
- data/lib/locales/fa.yml +0 -55
- data/lib/locales/fi.yml +0 -55
- data/lib/locales/fr.yml +0 -55
- data/lib/locales/fy.yml +0 -55
- data/lib/locales/gd.yml +0 -55
- data/lib/locales/gl.yml +0 -55
- data/lib/locales/he.yml +0 -55
- data/lib/locales/hi.yml +0 -55
- data/lib/locales/hr.yml +0 -55
- data/lib/locales/hu.yml +0 -55
- data/lib/locales/hy.yml +0 -55
- data/lib/locales/id.yml +0 -55
- data/lib/locales/is.yml +0 -55
- data/lib/locales/it.yml +0 -55
- data/lib/locales/ja.yml +0 -55
- data/lib/locales/ka.yml +0 -55
- data/lib/locales/kk.yml +0 -55
- data/lib/locales/km.yml +0 -55
- data/lib/locales/kn.yml +0 -55
- data/lib/locales/ko.yml +0 -55
- data/lib/locales/lb.yml +0 -55
- data/lib/locales/lo.yml +0 -55
- data/lib/locales/lt.yml +0 -55
- data/lib/locales/lv.yml +0 -55
- data/lib/locales/mg.yml +0 -55
- data/lib/locales/mk.yml +0 -55
- data/lib/locales/ml.yml +0 -55
- data/lib/locales/mn.yml +0 -55
- data/lib/locales/mr-IN.yml +0 -55
- data/lib/locales/ms.yml +0 -55
- data/lib/locales/nb.yml +0 -55
- data/lib/locales/ne.yml +0 -55
- data/lib/locales/nl.yml +0 -55
- data/lib/locales/nn.yml +0 -55
- data/lib/locales/oc.yml +0 -55
- data/lib/locales/or.yml +0 -55
- data/lib/locales/pa.yml +0 -55
- data/lib/locales/pl.yml +0 -55
- data/lib/locales/pt.yml +0 -55
- data/lib/locales/rm.yml +0 -55
- data/lib/locales/ro.yml +0 -55
- data/lib/locales/ru.yml +0 -55
- data/lib/locales/sc.yml +0 -55
- data/lib/locales/sk.yml +0 -55
- data/lib/locales/sl.yml +0 -55
- data/lib/locales/sq.yml +0 -55
- data/lib/locales/sr.yml +0 -55
- data/lib/locales/st.yml +0 -55
- data/lib/locales/sv.yml +0 -55
- data/lib/locales/sw.yml +0 -55
- data/lib/locales/ta.yml +0 -55
- data/lib/locales/te.yml +0 -55
- data/lib/locales/th.yml +0 -55
- data/lib/locales/tl.yml +0 -55
- data/lib/locales/tr.yml +0 -55
- data/lib/locales/tt.yml +0 -55
- data/lib/locales/ug.yml +0 -55
- data/lib/locales/uk.yml +0 -55
- data/lib/locales/ur.yml +0 -55
- data/lib/locales/uz.yml +0 -55
- data/lib/locales/vi.yml +0 -55
- data/lib/locales/wo.yml +0 -55
- data/lib/locales/zh-CN.yml +0 -55
- data/lib/locales/zh-HK.yml +0 -55
- data/lib/locales/zh-TW.yml +0 -55
- data/lib/locales/zh-YUE.yml +0 -55
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Ordered list of middlewares wrapping a task's lifecycle. Each middleware
|
|
5
|
+
# is a callable with the signature `call(task) { next_link.call }`; Runtime
|
|
6
|
+
# builds a nested chain and requires each middleware to yield to the next.
|
|
7
|
+
class Middlewares
|
|
8
|
+
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@registry = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param source [Middlewares] registry to duplicate
|
|
16
|
+
# @return [void]
|
|
17
|
+
def initialize_copy(source)
|
|
18
|
+
@registry = source.registry.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Inserts a middleware. With no `:at`, appends. With `:at`, inserts at
|
|
22
|
+
# the given (clamped) index — supports negative indexing. `:if`/`:unless`
|
|
23
|
+
# gates evaluated against the task at process time.
|
|
24
|
+
#
|
|
25
|
+
# @param callable [#call, nil] provide either this or a block
|
|
26
|
+
# @param block [#call, nil] middleware callable when `callable` is omitted
|
|
27
|
+
# @param options [Hash{Symbol => Object}]
|
|
28
|
+
# @option options [Symbol, Proc, #call] :if gate that must evaluate truthy
|
|
29
|
+
# @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy
|
|
30
|
+
# @option options [Integer] :at insertion index (see implementation)
|
|
31
|
+
# @return [Middlewares] self for chaining
|
|
32
|
+
# @raise [ArgumentError] when both or neither of `callable`/block are given,
|
|
33
|
+
# when the callable doesn't respond to `#call`, or when `:at` isn't an Integer
|
|
34
|
+
# @yield the middleware body, receiving `(task)` and `next_link` via block
|
|
35
|
+
def register(callable = nil, **options, &block)
|
|
36
|
+
middleware = callable || block
|
|
37
|
+
at = options.delete(:at)
|
|
38
|
+
|
|
39
|
+
if callable && block
|
|
40
|
+
raise ArgumentError, "provide either a callable or a block, not both"
|
|
41
|
+
elsif !middleware.respond_to?(:call)
|
|
42
|
+
raise ArgumentError, "middleware must respond to #call"
|
|
43
|
+
elsif !at.nil? && !at.is_a?(Integer)
|
|
44
|
+
raise ArgumentError, "at must be an Integer"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
entry = [middleware, options.freeze]
|
|
48
|
+
|
|
49
|
+
if at.nil?
|
|
50
|
+
registry << entry
|
|
51
|
+
else
|
|
52
|
+
at = [at.clamp(-registry.size - 1, registry.size), registry.size].min
|
|
53
|
+
registry.insert(at, entry)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Removes a middleware by reference or by index.
|
|
60
|
+
#
|
|
61
|
+
# @param middleware [#call, nil] the exact middleware to remove
|
|
62
|
+
# @param at [Integer, nil] index to remove
|
|
63
|
+
# @return [Middlewares] self for chaining
|
|
64
|
+
# @raise [ArgumentError] when neither or both of `middleware`/`:at` are given,
|
|
65
|
+
# or when `:at` isn't an Integer
|
|
66
|
+
def deregister(middleware = nil, at: nil)
|
|
67
|
+
if at.nil? && middleware.nil?
|
|
68
|
+
raise ArgumentError, "provide either a middleware or an at: index"
|
|
69
|
+
elsif !at.nil? && !middleware.nil?
|
|
70
|
+
raise ArgumentError, "provide either a middleware or an at: index, not both"
|
|
71
|
+
elsif !at.nil? && !at.is_a?(Integer)
|
|
72
|
+
raise ArgumentError, "at must be an Integer"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if at.nil?
|
|
76
|
+
registry.reject! { |mw, _opts| mw == middleware }
|
|
77
|
+
else
|
|
78
|
+
registry.delete_at(at)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def empty?
|
|
86
|
+
registry.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Integer]
|
|
90
|
+
def size
|
|
91
|
+
registry.size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Walks the middleware chain around `task`'s lifecycle. The final link
|
|
95
|
+
# yields to `block`, which is expected to run the actual lifecycle.
|
|
96
|
+
#
|
|
97
|
+
# @param task [Task]
|
|
98
|
+
# @yield the innermost link — the task's lifecycle body
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [MiddlewareError] when a middleware forgets to yield to `next_link`,
|
|
101
|
+
# which would otherwise silently skip the task
|
|
102
|
+
def process(task)
|
|
103
|
+
processed = false
|
|
104
|
+
count = registry.size
|
|
105
|
+
|
|
106
|
+
chain = lambda do |i|
|
|
107
|
+
if i == count
|
|
108
|
+
processed = true
|
|
109
|
+
yield
|
|
110
|
+
else
|
|
111
|
+
mw, opts = registry[i]
|
|
112
|
+
|
|
113
|
+
if Util.satisfied?(opts[:if], opts[:unless], task)
|
|
114
|
+
mw.call(task) { chain.call(i + 1) }
|
|
115
|
+
else
|
|
116
|
+
chain.call(i + 1)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
chain.call(0)
|
|
121
|
+
|
|
122
|
+
processed || begin
|
|
123
|
+
raise MiddlewareError, "middleware did not yield the next_link"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/cmdx/output.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# A single declared output. Runtime calls {#verify} after `work` to enforce
|
|
5
|
+
# presence on `task.context[name]` (every declared output is implicitly
|
|
6
|
+
# required) and to apply `:default`. `:if`/`:unless` gate verification entirely.
|
|
7
|
+
class Output
|
|
8
|
+
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# @param name [Symbol, String] output key (symbolized)
|
|
12
|
+
# @param options [Hash{Symbol => Object}] declaration options
|
|
13
|
+
# @option options [String] :description (also accepts `:desc`)
|
|
14
|
+
# @option options [Symbol, Proc, #call] :if
|
|
15
|
+
# @option options [Symbol, Proc, #call] :unless
|
|
16
|
+
# @option options [Object, Symbol, Proc, #call] :default
|
|
17
|
+
def initialize(name, **options)
|
|
18
|
+
@name = name.to_sym
|
|
19
|
+
@options = options.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String, nil]
|
|
23
|
+
def description
|
|
24
|
+
@options[:description] || @options[:desc]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Object, Symbol, Proc, #call, nil]
|
|
28
|
+
def default
|
|
29
|
+
@options[:default]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Symbol, Proc, #call, nil]
|
|
33
|
+
def condition_if
|
|
34
|
+
@options[:if]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Symbol, Proc, #call, nil]
|
|
38
|
+
def condition_unless
|
|
39
|
+
@options[:unless]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash{Symbol => Object}] serialized schema for `outputs_schema`
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
name:,
|
|
46
|
+
description:,
|
|
47
|
+
options: @options
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
|
|
52
|
+
# callers (e.g. Rails).
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash{Symbol => Object}]
|
|
55
|
+
def as_json(*)
|
|
56
|
+
to_h
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Serializes the output schema to a JSON string. Non-primitive entries in
|
|
60
|
+
# `:options` (Procs, arbitrary callables) emit via their stdlib `to_json`
|
|
61
|
+
# defaults.
|
|
62
|
+
#
|
|
63
|
+
# @param args [Array] forwarded to `Hash#to_json`
|
|
64
|
+
# @return [String]
|
|
65
|
+
def to_json(*args)
|
|
66
|
+
to_h.to_json(*args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Enforces the output contract against `task.context[name]` after `work` runs.
|
|
70
|
+
#
|
|
71
|
+
# Steps, in order:
|
|
72
|
+
# 1. Skips entirely when `:if`/`:unless` excludes it.
|
|
73
|
+
# 2. Reads the value from `task.context` and falls back to `:default` when nil.
|
|
74
|
+
# 3. Adds a `cmdx.outputs.missing` error when neither the key nor a default
|
|
75
|
+
# supplied a value (every declared output is implicitly required).
|
|
76
|
+
# 4. Writes the resolved value back to `task.context[name]`.
|
|
77
|
+
#
|
|
78
|
+
# @param task [Task] the running task whose context is inspected and mutated
|
|
79
|
+
# @return [void]
|
|
80
|
+
def verify(task)
|
|
81
|
+
return unless Util.satisfied?(condition_if, condition_unless, task)
|
|
82
|
+
|
|
83
|
+
key_provided = task.context.key?(name)
|
|
84
|
+
value = task.context[name]
|
|
85
|
+
value = apply_default(task) if value.nil?
|
|
86
|
+
|
|
87
|
+
if !key_provided && value.nil?
|
|
88
|
+
task.errors.add(name, I18nProxy.t("cmdx.outputs.missing"))
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
task.context[name] = value
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# @param task [Task]
|
|
98
|
+
# @return [Object, nil]
|
|
99
|
+
def apply_default(task)
|
|
100
|
+
return if default.nil?
|
|
101
|
+
|
|
102
|
+
case default
|
|
103
|
+
when Symbol
|
|
104
|
+
task.send(default)
|
|
105
|
+
when Proc
|
|
106
|
+
task.instance_exec(&default)
|
|
107
|
+
else
|
|
108
|
+
return default unless default.respond_to?(:call)
|
|
109
|
+
|
|
110
|
+
default.call(task)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/cmdx/outputs.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Registry of declared task outputs. Runtime verifies each output after
|
|
5
|
+
# `work` completes: presence and defaults run against values the task wrote
|
|
6
|
+
# to context.
|
|
7
|
+
class Outputs
|
|
8
|
+
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@registry = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param source [Outputs] registry to duplicate
|
|
16
|
+
# @return [void]
|
|
17
|
+
def initialize_copy(source)
|
|
18
|
+
@registry = source.registry.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Declares one or more output keys. All share the same `options`.
|
|
22
|
+
#
|
|
23
|
+
# @param keys [Array<Symbol>]
|
|
24
|
+
# @param options [Hash{Symbol => Object}] passed through to {Output#initialize}
|
|
25
|
+
# @option options [String] :description (also accepts `:desc`)
|
|
26
|
+
# @option options [Symbol, Proc, #call] :if
|
|
27
|
+
# @option options [Symbol, Proc, #call] :unless
|
|
28
|
+
# @option options [Object, Symbol, Proc, #call] :default
|
|
29
|
+
# @return [Outputs] self for chaining
|
|
30
|
+
def register(*keys, **options)
|
|
31
|
+
keys.each do |key|
|
|
32
|
+
output = Output.new(key, **options)
|
|
33
|
+
registry[output.name] = output
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param keys [Array<Symbol>]
|
|
40
|
+
# @return [Outputs] self for chaining
|
|
41
|
+
def deregister(*keys)
|
|
42
|
+
keys.each { |key| registry.delete(key.to_sym) }
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def empty?
|
|
48
|
+
registry.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
def size
|
|
53
|
+
registry.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Verifies every declared output against `task.context`. Adds any failures
|
|
57
|
+
# to `task.errors`.
|
|
58
|
+
#
|
|
59
|
+
# @param task [Task]
|
|
60
|
+
# @return [void]
|
|
61
|
+
def verify(task)
|
|
62
|
+
registry.each_value { |output| output.verify(task) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/cmdx/pipeline.rb
CHANGED
|
@@ -1,168 +1,181 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Runs a Workflow's declared task groups. Each group selects a strategy
|
|
5
|
+
# (`:sequential` by default, or `:parallel`). A group failure halts the
|
|
6
|
+
# pipeline by echoing the failed result's signal through `throw!`, which
|
|
7
|
+
# bubbles up through Runtime as the workflow's own failure.
|
|
8
|
+
#
|
|
9
|
+
# Groups may opt into batch semantics with `continue_on_failure: true`,
|
|
10
|
+
# in which case every task in the group runs to completion and all
|
|
11
|
+
# failures are aggregated into the workflow's `errors` (keyed as
|
|
12
|
+
# `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"`
|
|
13
|
+
# for bare `fail!` reasons) before the pipeline halts on the first
|
|
14
|
+
# failure (declaration order).
|
|
15
|
+
#
|
|
16
|
+
# @see Workflow
|
|
7
17
|
class Pipeline
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
SEQUENTIAL_REGEXP = /\Asequential\z/
|
|
11
|
-
private_constant :SEQUENTIAL_REGEXP
|
|
19
|
+
class << self
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
# @param workflow [Task] workflow instance whose class includes {Workflow}
|
|
22
|
+
# @return [void]
|
|
23
|
+
def execute(workflow)
|
|
24
|
+
new(workflow).execute
|
|
25
|
+
end
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
# @return [Workflow] The workflow instance
|
|
20
|
-
#
|
|
21
|
-
# @example
|
|
22
|
-
# pipeline.workflow.context[:status] # => "processing"
|
|
23
|
-
#
|
|
24
|
-
# @rbs @workflow: Workflow
|
|
25
|
-
attr_reader :workflow
|
|
27
|
+
end
|
|
26
28
|
|
|
27
|
-
# @param workflow [
|
|
28
|
-
#
|
|
29
|
-
# @return [Pipeline] A new pipeline instance
|
|
30
|
-
#
|
|
31
|
-
# @example
|
|
32
|
-
# pipeline = Pipeline.new(my_workflow)
|
|
33
|
-
#
|
|
34
|
-
# @rbs (Workflow workflow) -> void
|
|
29
|
+
# @param workflow [Task] workflow instance whose class includes {Workflow}
|
|
35
30
|
def initialize(workflow)
|
|
36
31
|
@workflow = workflow
|
|
32
|
+
@executed = []
|
|
37
33
|
end
|
|
38
34
|
|
|
39
|
-
#
|
|
35
|
+
# Iterates every group in the workflow's pipeline, respecting
|
|
36
|
+
# `:if`/`:unless` and the `:strategy` key. Any group that produces a
|
|
37
|
+
# failed result halts execution by throwing through the workflow.
|
|
40
38
|
#
|
|
41
|
-
#
|
|
39
|
+
# On halt, every previously executed task instance whose result is
|
|
40
|
+
# `success?` is sent `#rollback` (when defined) in reverse execution
|
|
41
|
+
# order, providing saga-style compensation. Each compensated result
|
|
42
|
+
# has its `:rolled_back` option flipped to `true`. Skipped tasks are
|
|
43
|
+
# excluded; the failing task itself is rolled back by {Runtime} and
|
|
44
|
+
# is not re-invoked here. Exceptions raised inside a compensator
|
|
45
|
+
# propagate — handling them is the developer's responsibility.
|
|
42
46
|
#
|
|
43
47
|
# @return [void]
|
|
44
|
-
#
|
|
45
|
-
# @
|
|
46
|
-
# Pipeline.execute(my_workflow)
|
|
47
|
-
#
|
|
48
|
-
# @rbs (Workflow workflow) -> void
|
|
49
|
-
def self.execute(workflow)
|
|
50
|
-
new(workflow).execute
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Executes the workflow by processing all task groups in sequence.
|
|
54
|
-
# Each group is evaluated against its conditions, and breakpoints are checked
|
|
55
|
-
# after each task execution to determine if workflow should continue or halt.
|
|
56
|
-
#
|
|
57
|
-
# @return [void]
|
|
58
|
-
#
|
|
59
|
-
# @example
|
|
60
|
-
# pipeline = Pipeline.new(my_workflow)
|
|
61
|
-
# pipeline.execute
|
|
62
|
-
#
|
|
63
|
-
# @rbs () -> void
|
|
48
|
+
# @raise [ArgumentError] for an unknown strategy
|
|
49
|
+
# @raise [StandardError] anything raised by a task's `#rollback`
|
|
64
50
|
def execute
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if group.options.key?(:breakpoints)
|
|
75
|
-
Utils::Normalize.statuses(group.options[:breakpoints])
|
|
51
|
+
@workflow.class.pipeline.each do |group|
|
|
52
|
+
next unless Util.satisfied?(group.options[:if], group.options[:unless], @workflow)
|
|
53
|
+
|
|
54
|
+
halt =
|
|
55
|
+
case strategy = group.options[:strategy]
|
|
56
|
+
when :sequential, NilClass
|
|
57
|
+
run_sequential(group)
|
|
58
|
+
when :parallel
|
|
59
|
+
run_parallel(group)
|
|
76
60
|
else
|
|
77
|
-
|
|
61
|
+
raise ArgumentError, "invalid strategy: #{strategy.inspect}"
|
|
78
62
|
end
|
|
79
63
|
|
|
80
|
-
|
|
64
|
+
next unless halt
|
|
65
|
+
|
|
66
|
+
rollback_executed!
|
|
67
|
+
@workflow.send(:throw!, halt)
|
|
81
68
|
end
|
|
82
69
|
end
|
|
83
70
|
|
|
84
71
|
private
|
|
85
72
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def execute_group_tasks(group, breakpoints)
|
|
99
|
-
case strategy = group.options[:strategy]
|
|
100
|
-
when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints)
|
|
101
|
-
when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints)
|
|
102
|
-
else raise "unknown execution strategy #{strategy.inspect}"
|
|
73
|
+
# @param group [Workflow::ExecutionGroup]
|
|
74
|
+
# @return [Result, nil] failed result to halt on, or nil when the group succeeds
|
|
75
|
+
def run_sequential(group)
|
|
76
|
+
continue = group.options[:continue_on_failure]
|
|
77
|
+
failures = group.tasks.each_with_object([]) do |task_class, bucket|
|
|
78
|
+
instance = task_class.new(@workflow.context)
|
|
79
|
+
result = instance.execute(strict: false)
|
|
80
|
+
@executed << [instance, result]
|
|
81
|
+
next unless result.failed?
|
|
82
|
+
|
|
83
|
+
bucket << result
|
|
84
|
+
break bucket unless continue
|
|
103
85
|
end
|
|
86
|
+
|
|
87
|
+
aggregate(failures, continue:)
|
|
104
88
|
end
|
|
105
89
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
90
|
+
# @param group [Workflow::ExecutionGroup]
|
|
91
|
+
# @return [Result, nil] failed result to halt on, or nil when the group succeeds
|
|
92
|
+
def run_parallel(group)
|
|
93
|
+
tasks = group.tasks
|
|
94
|
+
chain = Chain.current
|
|
95
|
+
size = group.options[:pool_size] || tasks.size
|
|
96
|
+
continue = group.options[:continue_on_failure]
|
|
97
|
+
entries = Array.new(tasks.size)
|
|
98
|
+
mutex = Mutex.new
|
|
99
|
+
seen_fail = false
|
|
100
|
+
cancelled = false
|
|
101
|
+
|
|
102
|
+
jobs = tasks.each_with_index.to_a
|
|
103
|
+
|
|
104
|
+
on_job = lambda do |(task_class, index)|
|
|
105
|
+
mutex.synchronize { return if cancelled }
|
|
106
|
+
|
|
107
|
+
Fiber[Chain::STORAGE_KEY] ||= chain
|
|
108
|
+
ctx_copy = @workflow.context.deep_dup
|
|
109
|
+
instance = task_class.new(ctx_copy)
|
|
110
|
+
result = instance.execute(strict: false)
|
|
111
|
+
|
|
112
|
+
mutex.synchronize do
|
|
113
|
+
entries[index] = [instance, result]
|
|
114
|
+
|
|
115
|
+
if result.failed? && !continue && !seen_fail
|
|
116
|
+
seen_fail = true
|
|
117
|
+
cancelled = true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
executor = @workflow.class.executors.resolve(group.options[:executor])
|
|
123
|
+
merger = @workflow.class.mergers.resolve(group.options[:merger])
|
|
124
|
+
|
|
125
|
+
executor.call(jobs:, concurrency: size, on_job:)
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
failures = []
|
|
128
|
+
entries.each do |entry|
|
|
129
|
+
next if entry.nil?
|
|
130
|
+
|
|
131
|
+
@executed << entry
|
|
132
|
+
_instance, result = entry
|
|
133
|
+
|
|
134
|
+
if result.failed?
|
|
135
|
+
failures << result
|
|
136
|
+
else
|
|
137
|
+
merger.call(@workflow.context, result)
|
|
138
|
+
end
|
|
126
139
|
end
|
|
140
|
+
|
|
141
|
+
aggregate(failures, continue:)
|
|
127
142
|
end
|
|
128
143
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count)
|
|
136
|
-
#
|
|
137
|
-
# @return [void]
|
|
138
|
-
#
|
|
139
|
-
# @raise [Fault] When a task result status matches a breakpoint
|
|
140
|
-
#
|
|
141
|
-
# @example
|
|
142
|
-
# execute_tasks_in_parallel(group, ["failed"])
|
|
143
|
-
#
|
|
144
|
-
# @rbs (untyped group, Array[String] breakpoints) -> void
|
|
145
|
-
def execute_tasks_in_parallel(group, breakpoints)
|
|
146
|
-
contexts = group.tasks.map { Context.new(workflow.context.to_h) }
|
|
147
|
-
ctx_pairs = group.tasks.zip(contexts)
|
|
148
|
-
pool_size = group.options.fetch(:pool_size, ctx_pairs.size)
|
|
149
|
-
|
|
150
|
-
results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context|
|
|
151
|
-
Chain.current = workflow.chain
|
|
152
|
-
task.execute(context)
|
|
153
|
-
end
|
|
144
|
+
def rollback_executed!
|
|
145
|
+
@executed.reverse_each do |instance, result|
|
|
146
|
+
next unless result.success?
|
|
147
|
+
next unless instance.respond_to?(:rollback)
|
|
148
|
+
|
|
149
|
+
instance.rollback
|
|
154
150
|
|
|
155
|
-
|
|
151
|
+
old_opts = result.instance_variable_get(:@options)
|
|
152
|
+
new_opts = old_opts.merge(rolled_back: true).freeze
|
|
153
|
+
result.instance_variable_set(:@options, new_opts)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
# @param failures [Array<Result>]
|
|
158
|
+
# @param continue [Boolean] when true, merges failures into the workflow's errors
|
|
159
|
+
# @return [Result, nil] first failure (echoed upstream), or nil when `failures` is empty
|
|
160
|
+
def aggregate(failures, continue:)
|
|
161
|
+
return if failures.empty?
|
|
162
|
+
return failures.first unless continue
|
|
163
|
+
|
|
164
|
+
failures.each do |result|
|
|
165
|
+
prefix = result.task.name
|
|
166
|
+
|
|
167
|
+
if result.errors.empty?
|
|
168
|
+
message = I18nProxy.tr(result.reason)
|
|
169
|
+
@workflow.errors.add(:"#{prefix}.#{result.status}", message)
|
|
170
|
+
else
|
|
171
|
+
result.errors.each do |key, messages|
|
|
172
|
+
namespaced = :"#{prefix}.#{key}"
|
|
173
|
+
messages.each { |message| @workflow.errors.add(namespaced, message) }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
:"#{faulted.last.status}!",
|
|
162
|
-
Locale.t("cmdx.reasons.unspecified"),
|
|
163
|
-
source: :parallel,
|
|
164
|
-
faults: faulted.map(&:to_h)
|
|
165
|
-
)
|
|
178
|
+
failures.first
|
|
166
179
|
end
|
|
167
180
|
|
|
168
181
|
end
|
data/lib/cmdx/railtie.rb
CHANGED
|
@@ -1,47 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
|
-
# Rails integration
|
|
5
|
-
#
|
|
4
|
+
# Rails integration. Loaded only when `Rails::Railtie` is defined. Wires the
|
|
5
|
+
# app's `I18n.load_path` so CMDx locale files for each available locale are
|
|
6
|
+
# available, and points the CMDx logger and backtrace cleaner at Rails.
|
|
6
7
|
class Railtie < Rails::Railtie
|
|
7
8
|
|
|
8
9
|
railtie_name :cmdx
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# all locales are properly registered.
|
|
15
|
-
#
|
|
16
|
-
# @param app [Rails::Application] the Rails application instance
|
|
17
|
-
#
|
|
18
|
-
# @raise [LoadError] if locale files cannot be loaded
|
|
19
|
-
#
|
|
20
|
-
# @example
|
|
21
|
-
# # This initializer runs automatically when Rails starts
|
|
22
|
-
# # It will load locales like en.yml, es.yml, fr.yml if they exist
|
|
23
|
-
# # in the CMDx gem's locales directory
|
|
24
|
-
#
|
|
25
|
-
# @rbs (untyped app) -> void
|
|
26
|
-
initializer("cmdx.configure_locales") do |app|
|
|
27
|
-
Utils::Wrap.array(app.config.i18n.available_locales).each do |locale|
|
|
28
|
-
path = CMDx.gem_path.join("lib/locales/#{locale}.yml")
|
|
29
|
-
next unless File.file?(path)
|
|
11
|
+
initializer("cmdx.configure_rails") do |app|
|
|
12
|
+
available_locales = app.config.i18n.available_locales.join(",")
|
|
13
|
+
locale_path = File.expand_path("../locales/{#{available_locales}}.yml", __dir__)
|
|
14
|
+
::I18n.load_path += Dir[locale_path]
|
|
30
15
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
::I18n.reload!
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Configures the backtrace cleaner for CMDx in a Rails environment.
|
|
38
|
-
#
|
|
39
|
-
# Sets the backtrace cleaner to the Rails backtrace cleaner.
|
|
40
|
-
#
|
|
41
|
-
# @rbs () -> void
|
|
42
|
-
initializer("cmdx.backtrace_cleaner") do
|
|
43
|
-
CMDx.configuration.backtrace_cleaner = lambda do |backtrace|
|
|
44
|
-
Rails.backtrace_cleaner.clean(backtrace)
|
|
16
|
+
CMDx.configure do |config|
|
|
17
|
+
config.logger = Rails.logger
|
|
18
|
+
config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) }
|
|
45
19
|
end
|
|
46
20
|
end
|
|
47
21
|
|