actionable-rb 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- metadata +132 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# The value object returned by {Action.run_each}: an ordered, +Enumerable+
|
|
5
|
+
# collection of the per-item {Result}s, plus aggregate predicates over them
|
|
6
|
+
# (decision D19). Each item ran independently — one {Result} per element, in
|
|
7
|
+
# input order — so a failure in one never affects another.
|
|
8
|
+
#
|
|
9
|
+
# batch = ImportRow.run_each(rows) { |row| {row: row} }
|
|
10
|
+
# batch.all_ok? # => false
|
|
11
|
+
# batch.failures.size # => 2
|
|
12
|
+
# batch.to_api_h # => [{index: 0, status: :success, ...}, ...]
|
|
13
|
+
class BatchResult
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
# @return [Array<Result>] the per-item results, in input order
|
|
17
|
+
attr_reader :results
|
|
18
|
+
|
|
19
|
+
# @param results [Array<Result>]
|
|
20
|
+
def initialize(results)
|
|
21
|
+
@results = results.to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @yieldparam result [Result]
|
|
25
|
+
# @return [Enumerator, self]
|
|
26
|
+
def each(&block)
|
|
27
|
+
return to_enum(:each) unless block
|
|
28
|
+
|
|
29
|
+
@results.each(&block)
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param index [Integer]
|
|
34
|
+
# @return [Result, nil] the result at +index+
|
|
35
|
+
def [](index)
|
|
36
|
+
@results[index]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Integer] the number of results
|
|
40
|
+
def size
|
|
41
|
+
@results.size
|
|
42
|
+
end
|
|
43
|
+
alias length size
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether the batch ran no items
|
|
46
|
+
def empty?
|
|
47
|
+
@results.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] whether every result is +ok?+ (success or skip);
|
|
51
|
+
# vacuously true for an empty batch
|
|
52
|
+
def all_ok?
|
|
53
|
+
@results.all?(&:ok?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] whether any result is a failure
|
|
57
|
+
def any_failure?
|
|
58
|
+
@results.any?(&:failure?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether the batch is mixed — at least one failure and at
|
|
62
|
+
# least one +ok?+ result
|
|
63
|
+
def partial?
|
|
64
|
+
any_failure? && @results.any?(&:ok?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<Result>] the successful results
|
|
68
|
+
def successes
|
|
69
|
+
@results.select(&:success?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<Result>] the failed results
|
|
73
|
+
def failures
|
|
74
|
+
@results.select(&:failure?)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array<Result>] the skipped results
|
|
78
|
+
def skips
|
|
79
|
+
@results.select(&:skipped?)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The batch as an array of API elements, each tagged with its position
|
|
83
|
+
# (see {Result#to_api_h}). Ready to render as a JSON array of per-item
|
|
84
|
+
# statuses.
|
|
85
|
+
#
|
|
86
|
+
# @param id_field [Symbol] the output field to surface as +:id+
|
|
87
|
+
# @return [Array<Hash{Symbol=>Object}>]
|
|
88
|
+
def to_api_h(id_field: :id)
|
|
89
|
+
@results.each_with_index.map { |result, index| result.to_api_h(index: index, id_field: id_field) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# Base class for every error this gem raises. Rescue +Actionable::Error+ to
|
|
5
|
+
# catch the whole family. Genuine errors are never caught by the runner for
|
|
6
|
+
# control flow — they propagate to the caller (decision D4).
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when an action is misconfigured — a reserved output field name, or a
|
|
10
|
+
# declared step whose method isn't implemented. Surfaced early and clearly so
|
|
11
|
+
# humans and agents get an actionable message instead of a deep +NoMethodError+
|
|
12
|
+
# (decision D18).
|
|
13
|
+
class DefinitionError < Error; end
|
|
14
|
+
|
|
15
|
+
# The throw/catch tag used to unwind an action's step pipeline early.
|
|
16
|
+
# Halting (+fail!+ / +succeed!+ / +halt!+) throws this symbol; the runner
|
|
17
|
+
# catches it via {Actionable.catch_halt}. This is control flow, NOT an
|
|
18
|
+
# exception (decision D4).
|
|
19
|
+
# @return [Symbol]
|
|
20
|
+
HALT = :actionable_halt
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Run +block+ inside the halt catch point.
|
|
25
|
+
#
|
|
26
|
+
# Returns the block's value when nothing is thrown, or the value passed with
|
|
27
|
+
# +throw Actionable::HALT, value+ when the pipeline halts (+nil+ for a bare
|
|
28
|
+
# +throw Actionable::HALT+). A raised StandardError is deliberately NOT
|
|
29
|
+
# caught — it propagates to the caller (decision D4).
|
|
30
|
+
#
|
|
31
|
+
# @yieldreturn [Object]
|
|
32
|
+
# @return [Object] the block's value, or the thrown halt value
|
|
33
|
+
def catch_halt(&block)
|
|
34
|
+
catch(HALT, &block)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Actionable
|
|
7
|
+
# An ordered record of the steps a run executed, when measurement is enabled
|
|
8
|
+
# (decision D10). Each entry is a {History::Step}; nested action steps carry
|
|
9
|
+
# their child's +History+.
|
|
10
|
+
class History
|
|
11
|
+
# One recorded step: which section it ran in, its name, when it started, how
|
|
12
|
+
# long it took, the result code it produced (if any), and — for action
|
|
13
|
+
# steps — the nested child {History}.
|
|
14
|
+
class Step
|
|
15
|
+
# @return [Symbol] +:main+, +:success+, +:failure+, or +:always+
|
|
16
|
+
attr_reader :section
|
|
17
|
+
# @return [Symbol, String, Class] the step's name
|
|
18
|
+
attr_reader :name
|
|
19
|
+
# @return [Time] wall-clock start time
|
|
20
|
+
attr_reader :started_at
|
|
21
|
+
# @return [Float] elapsed seconds (monotonic)
|
|
22
|
+
attr_accessor :duration
|
|
23
|
+
# @return [Symbol, nil] the result code this step recorded, if it set one
|
|
24
|
+
attr_accessor :code
|
|
25
|
+
# @return [History, nil] the nested action's history, for action steps
|
|
26
|
+
attr_accessor :nested
|
|
27
|
+
|
|
28
|
+
# @param section [Symbol]
|
|
29
|
+
# @param name [Symbol, String, Class]
|
|
30
|
+
# @param started_at [Time]
|
|
31
|
+
def initialize(section:, name:, started_at:)
|
|
32
|
+
@section = section
|
|
33
|
+
@name = name
|
|
34
|
+
@started_at = started_at
|
|
35
|
+
@duration = 0.0
|
|
36
|
+
@code = nil
|
|
37
|
+
@nested = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{Symbol=>Object}] a JSON-ready hash (nested history recurses)
|
|
41
|
+
def as_json(*)
|
|
42
|
+
{
|
|
43
|
+
section: section,
|
|
44
|
+
name: name.is_a?(Class) ? name.name : name,
|
|
45
|
+
started_at: started_at.iso8601(6),
|
|
46
|
+
duration: duration,
|
|
47
|
+
code: code,
|
|
48
|
+
history: nested&.as_json
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Array<Step>] the recorded steps, in execution order
|
|
54
|
+
attr_reader :steps
|
|
55
|
+
|
|
56
|
+
def initialize
|
|
57
|
+
@steps = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Append a recorded step.
|
|
61
|
+
#
|
|
62
|
+
# @param step [Step]
|
|
63
|
+
# @return [self]
|
|
64
|
+
def <<(step)
|
|
65
|
+
@steps << step
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] whether any step has been recorded
|
|
70
|
+
def empty?
|
|
71
|
+
@steps.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Float] the summed duration of every recorded step
|
|
75
|
+
def took
|
|
76
|
+
@steps.sum(&:duration)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Array<Hash>] each step as a JSON-ready hash
|
|
80
|
+
def as_json(*)
|
|
81
|
+
@steps.map(&:as_json)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [String] the history serialized to JSON via Oj (decision D15).
|
|
85
|
+
# +:compat+ mode emits plain JSON (string keys, symbols as strings) rather
|
|
86
|
+
# than Oj's default object-marker encoding.
|
|
87
|
+
def to_json(*)
|
|
88
|
+
Oj.dump(as_json, mode: :compat)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# Runtime selection of an input schema from a discriminator value (decision
|
|
5
|
+
# D19, declared with +input_for+). Each branch maps a match value to a
|
|
6
|
+
# FieldStruct schema; an optional +default+ catches the rest. Lets a
|
|
7
|
+
# polymorphic payload (e.g. +event_type+ → shape) use typed, validated input.
|
|
8
|
+
#
|
|
9
|
+
# input_for :event_type do
|
|
10
|
+
# on 'sale', SaleShape
|
|
11
|
+
# on 'refund', RefundShape
|
|
12
|
+
# default UnknownShape
|
|
13
|
+
# end
|
|
14
|
+
class InputDispatch
|
|
15
|
+
# Collects +on+/+default+ branch declarations inside an +input_for+ block.
|
|
16
|
+
class Builder
|
|
17
|
+
# @return [Array<Array(Object, Class<FieldStruct::Base>)>] ordered [match, schema] pairs
|
|
18
|
+
attr_reader :branches
|
|
19
|
+
# @return [Class<FieldStruct::Base>, nil] the fallback schema, if declared
|
|
20
|
+
attr_reader :default_schema
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@branches = []
|
|
24
|
+
@default_schema = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Register a branch. +value+ matches the discriminator by +==+, by
|
|
28
|
+
# Array membership, or by +Regexp#match?+ (see {ValueMatch}).
|
|
29
|
+
#
|
|
30
|
+
# @param value [Object, Array, Regexp]
|
|
31
|
+
# @param schema [Class<FieldStruct::Base>]
|
|
32
|
+
# @return [void]
|
|
33
|
+
def on(value, schema)
|
|
34
|
+
@branches << [value, schema]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Register the fallback schema, used when no +on+ branch matches.
|
|
38
|
+
#
|
|
39
|
+
# @param schema [Class<FieldStruct::Base>]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def default(schema)
|
|
42
|
+
@default_schema = schema
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Symbol] the discriminator field read from +.run+'s kwargs
|
|
47
|
+
attr_reader :discriminator
|
|
48
|
+
# @return [Array<Array(Object, Class<FieldStruct::Base>)>] ordered [match, schema] pairs
|
|
49
|
+
attr_reader :branches
|
|
50
|
+
# @return [Class<FieldStruct::Base>, nil] the fallback schema
|
|
51
|
+
attr_reader :default_schema
|
|
52
|
+
|
|
53
|
+
# Build a dispatch from an +input_for+ declaration.
|
|
54
|
+
#
|
|
55
|
+
# @param discriminator [Symbol, String] the kwarg whose value selects the schema
|
|
56
|
+
# @yield the branch declarations (+on+ / +default+)
|
|
57
|
+
# @return [InputDispatch]
|
|
58
|
+
def self.define(discriminator, &block)
|
|
59
|
+
builder = Builder.new
|
|
60
|
+
builder.instance_eval(&block)
|
|
61
|
+
new(discriminator.to_sym, builder.branches, builder.default_schema)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param discriminator [Symbol]
|
|
65
|
+
# @param branches [Array<Array(Object, Class<FieldStruct::Base>)>]
|
|
66
|
+
# @param default_schema [Class<FieldStruct::Base>, nil]
|
|
67
|
+
def initialize(discriminator, branches, default_schema)
|
|
68
|
+
@discriminator = discriminator
|
|
69
|
+
@branches = branches
|
|
70
|
+
@default_schema = default_schema
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The schema for a discriminator value: the first matching branch, else the
|
|
74
|
+
# default, else +nil+ (no match and no default).
|
|
75
|
+
#
|
|
76
|
+
# @param value [Object] the discriminator value
|
|
77
|
+
# @return [Class<FieldStruct::Base>, nil]
|
|
78
|
+
def schema_for(value)
|
|
79
|
+
@branches.each { |branch_value, schema| return schema if ValueMatch.matches?(branch_value, value) }
|
|
80
|
+
@default_schema
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Array<Class<FieldStruct::Base>>] every distinct schema reachable
|
|
84
|
+
# through this dispatch (branches plus default)
|
|
85
|
+
def schemas
|
|
86
|
+
(@branches.map(&:last) + [@default_schema]).compact.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Array<Object>] the branch match values, in declaration order
|
|
90
|
+
def variants
|
|
91
|
+
@branches.map(&:first)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional background-job integration (decision D19). Load it explicitly with
|
|
4
|
+
# `require 'actionable/job'`. Framework-agnostic and pure Ruby — it maps an
|
|
5
|
+
# action {Actionable::Result} to a queue *disposition* and provides a {Mixin}
|
|
6
|
+
# you include into a Sidekiq worker or ActiveJob job. Never loaded by the core
|
|
7
|
+
# entry point.
|
|
8
|
+
require_relative '../actionable' # ensure the core is loaded (self-contained entry)
|
|
9
|
+
|
|
10
|
+
module Actionable
|
|
11
|
+
# Runs an action inside a background job and maps its result to a queue
|
|
12
|
+
# disposition: a success or skip is done (+:ack+); a failure either retries
|
|
13
|
+
# (transient) or is discarded (permanent). Genuine exceptions are never
|
|
14
|
+
# swallowed — they propagate so the queue's own retry/backoff handles them
|
|
15
|
+
# (invariant 2).
|
|
16
|
+
module Job
|
|
17
|
+
# Failure codes that should NOT be retried — a re-run can't fix bad input or
|
|
18
|
+
# a missing record, so the job is discarded rather than retried. Override
|
|
19
|
+
# per job via {Mixin#actionable_permanent_codes}.
|
|
20
|
+
# @return [Array<Symbol>]
|
|
21
|
+
DEFAULT_PERMANENT_CODES = %i[invalid_input invalid_output not_found].freeze
|
|
22
|
+
|
|
23
|
+
# Raised by {Mixin#run_actionable} for a retryable failure, so the host
|
|
24
|
+
# queue (Sidekiq / ActiveJob) sees a raised error and applies its retry
|
|
25
|
+
# policy. Carries the offending result.
|
|
26
|
+
class RetryableFailure < StandardError
|
|
27
|
+
# @return [Result] the failed result that triggered the retry
|
|
28
|
+
attr_reader :result
|
|
29
|
+
|
|
30
|
+
# @param result [Result] a {Failure}, or a {Skipped} retried because its
|
|
31
|
+
# metadata says +retry: true+ (see {RetryMetadata#honor_retry_metadata})
|
|
32
|
+
def initialize(result)
|
|
33
|
+
@result = result
|
|
34
|
+
verb = result.skipped? ? 'skipped' : 'failed'
|
|
35
|
+
super("actionable run #{verb} with retryable code #{result.code.inspect}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The +honor_retry_metadata+ class macro (owner ruling P10). Extended onto
|
|
40
|
+
# {Actionable::Action} by +actionable/job+, so an action can opt in to having
|
|
41
|
+
# {Mixin#run_actionable} act on the +retry:+ metadata of its declared codes.
|
|
42
|
+
# Metadata stays neutral by default (decisions D20/D21).
|
|
43
|
+
module RetryMetadata
|
|
44
|
+
# Opt this action in (or, with +enabled: false+, back out) to {Job}
|
|
45
|
+
# honoring the +retry:+ metadata of its declared failures and skips:
|
|
46
|
+
# +retry: true+ retries the job, +retry: false+ does not. A code with no
|
|
47
|
+
# +retry:+ key keeps the default mapping. Inherited; a subclass can turn
|
|
48
|
+
# it off.
|
|
49
|
+
#
|
|
50
|
+
# class Reindex < Actionable::Action
|
|
51
|
+
# honor_retry_metadata
|
|
52
|
+
# skips :not_ready, retry: true # the job retries until the index is ready
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# @param enabled [Boolean]
|
|
56
|
+
# @return [void]
|
|
57
|
+
def honor_retry_metadata(enabled: true)
|
|
58
|
+
@honor_retry_metadata = enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether {Job} honors this action's +retry:+ metadata, inherited from
|
|
62
|
+
# ancestors when not set locally. +false+ unless an ancestor opted in.
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def honor_retry_metadata?
|
|
66
|
+
return @honor_retry_metadata if defined?(@honor_retry_metadata)
|
|
67
|
+
return superclass.honor_retry_metadata? if superclass.respond_to?(:honor_retry_metadata?)
|
|
68
|
+
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module_function
|
|
74
|
+
|
|
75
|
+
# Map a result to a queue disposition.
|
|
76
|
+
#
|
|
77
|
+
# With +honor_retry: true+, a failure or skip whose metadata has a +retry:+
|
|
78
|
+
# key is decided by it instead: truthy is +:retry+, falsy is +:discard+ for
|
|
79
|
+
# a failure and +:ack+ for a skip. Results without the key, and successes,
|
|
80
|
+
# map as usual.
|
|
81
|
+
#
|
|
82
|
+
# @param result [Result] the action's result
|
|
83
|
+
# @param permanent [Array<Symbol>] failure codes treated as non-retryable
|
|
84
|
+
# @param honor_retry [Boolean] act on the result's +retry:+ metadata
|
|
85
|
+
# @return [:ack, :retry, :discard] +:ack+ for a success or skip; +:discard+
|
|
86
|
+
# for a failure whose code is +permanent+; +:retry+ for any other failure
|
|
87
|
+
def disposition(result, permanent: DEFAULT_PERMANENT_CODES, honor_retry: false)
|
|
88
|
+
return metadata_disposition(result) if honor_retry && retry_declared?(result)
|
|
89
|
+
return :ack if result.ok?
|
|
90
|
+
|
|
91
|
+
permanent.include?(result.code) ? :discard : :retry
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param result [Result]
|
|
95
|
+
# @return [Boolean] whether the result carries +retry:+ metadata
|
|
96
|
+
def retry_declared?(result)
|
|
97
|
+
!result.success? && result.metadata.key?(:retry)
|
|
98
|
+
end
|
|
99
|
+
private_class_method :retry_declared?
|
|
100
|
+
|
|
101
|
+
# @param result [Failure, Skipped] a result whose metadata has +retry:+
|
|
102
|
+
# @return [:ack, :retry, :discard]
|
|
103
|
+
def metadata_disposition(result)
|
|
104
|
+
return :retry if result.metadata[:retry]
|
|
105
|
+
|
|
106
|
+
result.failure? ? :discard : :ack
|
|
107
|
+
end
|
|
108
|
+
private_class_method :metadata_disposition
|
|
109
|
+
|
|
110
|
+
# Mix into a Sidekiq worker or ActiveJob job. Call {#run_actionable} from
|
|
111
|
+
# +perform+; it runs the action, records the result on {#actionable_result},
|
|
112
|
+
# and raises {RetryableFailure} for a retryable failure (so the queue
|
|
113
|
+
# retries) — returning normally for +:ack+ and +:discard+.
|
|
114
|
+
#
|
|
115
|
+
# class ProjectEventJob
|
|
116
|
+
# include Sidekiq::Job
|
|
117
|
+
# include Actionable::Job::Mixin
|
|
118
|
+
# def perform(event_id) = run_actionable(ProjectApiEvent, event_id: event_id)
|
|
119
|
+
# end
|
|
120
|
+
module Mixin
|
|
121
|
+
# @return [Result, nil] the result of the most recent {#run_actionable}
|
|
122
|
+
attr_reader :actionable_result
|
|
123
|
+
|
|
124
|
+
# Run +action_class+ with the given arguments, map the outcome, and either
|
|
125
|
+
# return the result (+:ack+ / +:discard+) or raise {RetryableFailure}
|
|
126
|
+
# (+:retry+). A genuine exception inside the action propagates unchanged.
|
|
127
|
+
# When the action opted in with +honor_retry_metadata+, its declared
|
|
128
|
+
# +retry:+ metadata decides (see {Job.disposition}).
|
|
129
|
+
#
|
|
130
|
+
# @param action_class [Class<Actionable::Action>]
|
|
131
|
+
# @param args [Object] positional arguments, forwarded to +.run+
|
|
132
|
+
# @param kwargs [Object] keyword arguments, forwarded to +.run+
|
|
133
|
+
# @return [Result] the run's result (for an ack/discard outcome)
|
|
134
|
+
# @raise [RetryableFailure] when the failure should be retried
|
|
135
|
+
def run_actionable(action_class, *args, **kwargs)
|
|
136
|
+
@actionable_result = action_class.run(*args, **kwargs)
|
|
137
|
+
disposition = Actionable::Job.disposition(
|
|
138
|
+
@actionable_result,
|
|
139
|
+
permanent: actionable_permanent_codes,
|
|
140
|
+
honor_retry: action_class.respond_to?(:honor_retry_metadata?) && action_class.honor_retry_metadata?
|
|
141
|
+
)
|
|
142
|
+
raise RetryableFailure, @actionable_result if disposition == :retry
|
|
143
|
+
|
|
144
|
+
@actionable_result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Array<Symbol>] failure codes this job treats as non-retryable;
|
|
148
|
+
# override to customize. Defaults to {DEFAULT_PERMANENT_CODES}.
|
|
149
|
+
def actionable_permanent_codes
|
|
150
|
+
DEFAULT_PERMANENT_CODES
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Wire the adapter into the core: the honor_retry_metadata macro onto Action.
|
|
157
|
+
Actionable::Action.extend(Actionable::Job::RetryMetadata)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# Thread-local measurement context (decision D10). It lets a measuring run
|
|
5
|
+
# cascade into the nested actions it invokes, and lets an action step attach
|
|
6
|
+
# its child's history to the step record currently open in the parent runner.
|
|
7
|
+
#
|
|
8
|
+
# All state is per-thread and saved/restored around each scope, so concurrent
|
|
9
|
+
# runs on different threads never interfere.
|
|
10
|
+
module Measurement
|
|
11
|
+
class << self
|
|
12
|
+
# @return [Boolean] whether the current thread is inside a measuring run
|
|
13
|
+
def active?
|
|
14
|
+
store[:active]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [History::Step, nil] the step record nested history attaches to
|
|
18
|
+
def open_step
|
|
19
|
+
store[:open_step]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Mark measurement active for the block, so nested runs measure too.
|
|
23
|
+
#
|
|
24
|
+
# @yieldreturn [Object]
|
|
25
|
+
# @return [Object] the block's value
|
|
26
|
+
def measuring(&)
|
|
27
|
+
with({active: true}, &)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Make +record+ the open step for the block, so a nested action step can
|
|
31
|
+
# attach its child's history to it.
|
|
32
|
+
#
|
|
33
|
+
# @param record [History::Step]
|
|
34
|
+
# @yieldreturn [Object]
|
|
35
|
+
# @return [Object] the block's value
|
|
36
|
+
def recording(record, &)
|
|
37
|
+
with({open_step: record}, &)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# This thread's measurement state: +:active+ and +:open_step+.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash{Symbol=>Object}]
|
|
45
|
+
def store
|
|
46
|
+
Thread.current[:actionable_measurement] ||= {active: false, open_step: nil}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Merge +changes+ into the state for the block, restoring it afterwards.
|
|
50
|
+
#
|
|
51
|
+
# @param changes [Hash{Symbol=>Object}] the keys to override
|
|
52
|
+
# @yield the scope the changes apply to
|
|
53
|
+
# @yieldreturn [Object]
|
|
54
|
+
# @return [Object] the block's value
|
|
55
|
+
def with(changes)
|
|
56
|
+
previous = store.dup
|
|
57
|
+
store.merge!(changes)
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
Thread.current[:actionable_measurement] = previous
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
module Rails
|
|
5
|
+
# The +after_commit+ hook kind. Prepended onto {Actionable::Action}'s
|
|
6
|
+
# singleton class by +actionable/rails+, so the core never knows about it
|
|
7
|
+
# (decision D8).
|
|
8
|
+
#
|
|
9
|
+
# An +after_commit+ hook runs once, after the database work of a
|
|
10
|
+
# successful run has really committed: it is registered with
|
|
11
|
+
# +ActiveRecord.after_all_transactions_commit+ when the run ends, so it
|
|
12
|
+
# waits for the outermost open transaction (not the action's own
|
|
13
|
+
# savepoint), runs immediately when none is open, and never runs if any
|
|
14
|
+
# enclosing transaction rolls back.
|
|
15
|
+
module AfterCommit
|
|
16
|
+
# The first ActiveRecord version with +after_all_transactions_commit+.
|
|
17
|
+
# @return [String]
|
|
18
|
+
MINIMUM_ACTIVE_RECORD = '7.2'
|
|
19
|
+
|
|
20
|
+
# Declare a hook that runs after the outermost database transaction
|
|
21
|
+
# commits, iff the run's final result is a {Success}.
|
|
22
|
+
#
|
|
23
|
+
# after_commit :send_receipt
|
|
24
|
+
# after_commit NotifyWarehouse, input: %i[order]
|
|
25
|
+
#
|
|
26
|
+
# The run is over by then: the result has been returned (or is about to
|
|
27
|
+
# be, when no transaction is open), so the hook cannot change it, a
|
|
28
|
+
# halting verb only stops the remaining +after_commit+ hooks, and the
|
|
29
|
+
# hook is not recorded in the history. An exception it raises propagates
|
|
30
|
+
# from wherever the commit happens: the outer +transaction+ call, or
|
|
31
|
+
# +.run+ itself when no transaction was open.
|
|
32
|
+
#
|
|
33
|
+
# @param target [Symbol, String, Class<Actionable::Action>] the instance
|
|
34
|
+
# method or nested action to run
|
|
35
|
+
# @param options [Object] step options (+:if+, +:unless+, and for a
|
|
36
|
+
# nested action +:input+)
|
|
37
|
+
# @raise [DefinitionError] when ActiveRecord is not loaded or predates
|
|
38
|
+
# +after_all_transactions_commit+
|
|
39
|
+
# @return [Set<Steps::Base>]
|
|
40
|
+
def after_commit(target, **options)
|
|
41
|
+
ensure_after_commit_supported!
|
|
42
|
+
declare(after_commit_hooks, Steps.build(target, **options))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Hooks that run after the outermost transaction commits a successful run.
|
|
46
|
+
# @return [Set<Steps::Base>]
|
|
47
|
+
def after_commit_hooks
|
|
48
|
+
@after_commit_hooks ||= Set.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# {Actionable::Action.describe}, with the +after_commit+ hooks listed as
|
|
52
|
+
# one more +:hooks+ section, so {Actionable::Action.describe_text} shows
|
|
53
|
+
# them too (only when there are any, like the core sections).
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash{Symbol=>Object}]
|
|
56
|
+
def describe
|
|
57
|
+
summary = super
|
|
58
|
+
summary[:hooks] = summary[:hooks].merge(after_commit: after_commit_hooks.map(&:name))
|
|
59
|
+
summary
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Give each subclass its own copy of the inherited +after_commit+ hooks.
|
|
63
|
+
#
|
|
64
|
+
# @param subclass [Class]
|
|
65
|
+
# @return [void]
|
|
66
|
+
def inherited(subclass)
|
|
67
|
+
super
|
|
68
|
+
subclass.instance_variable_set(:@after_commit_hooks, after_commit_hooks.dup)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Extend the run-start guard (decision D18) to the +after_commit+ hooks.
|
|
74
|
+
#
|
|
75
|
+
# @param instance [Action]
|
|
76
|
+
# @return [void]
|
|
77
|
+
def ensure_steps_implemented!(instance)
|
|
78
|
+
super
|
|
79
|
+
missing = after_commit_hooks.flat_map(&:required_methods).uniq.reject { |method| implements?(instance, method) }
|
|
80
|
+
return if missing.empty?
|
|
81
|
+
|
|
82
|
+
raise DefinitionError,
|
|
83
|
+
"#{name || "action"} declares after_commit hook(s) calling #{missing.map(&:inspect).join(", ")} " \
|
|
84
|
+
'but does not implement them'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Extend the recording-verb position check (owner ruling R5) to the
|
|
88
|
+
# +after_commit+ hooks.
|
|
89
|
+
#
|
|
90
|
+
# @return [Array<Steps::Base>]
|
|
91
|
+
def verb_checked_steps
|
|
92
|
+
super + after_commit_hooks.to_a
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @raise [DefinitionError] when +after_all_transactions_commit+ is unavailable
|
|
96
|
+
# @return [void]
|
|
97
|
+
def ensure_after_commit_supported!
|
|
98
|
+
return if defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
|
|
99
|
+
|
|
100
|
+
raise DefinitionError,
|
|
101
|
+
"after_commit needs ActiveRecord >= #{MINIMUM_ACTIVE_RECORD} " \
|
|
102
|
+
'(ActiveRecord.after_all_transactions_commit); load a supported ActiveRecord before declaring it'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Prepended onto {Actionable::Runner} (outside {RunnerTransaction}) to
|
|
107
|
+
# register a successful run's +after_commit+ hooks once the run, and its
|
|
108
|
+
# own transaction, are over.
|
|
109
|
+
module RunnerAfterCommit
|
|
110
|
+
# @return [Result]
|
|
111
|
+
def run
|
|
112
|
+
result = super
|
|
113
|
+
schedule_after_commit if result.success?
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# @return [void]
|
|
120
|
+
def schedule_after_commit
|
|
121
|
+
klass = @instance.class
|
|
122
|
+
return unless klass.respond_to?(:after_commit_hooks)
|
|
123
|
+
|
|
124
|
+
hooks = klass.after_commit_hooks.to_a
|
|
125
|
+
return if hooks.empty?
|
|
126
|
+
|
|
127
|
+
::ActiveRecord.after_all_transactions_commit { run_after_commit_hooks(hooks) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Run the hooks in order. A halt stops the remaining ones; nothing they
|
|
131
|
+
# record reaches the already-returned result.
|
|
132
|
+
#
|
|
133
|
+
# @param hooks [Array<Steps::Base>]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def run_after_commit_hooks(hooks)
|
|
136
|
+
Actionable.catch_halt do
|
|
137
|
+
hooks.each { |hook| hook.call(@instance) unless hook.skip?(@instance) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|