geneva_drive 0.4.0 → 0.6.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 +38 -0
- data/MANUAL.md +1037 -355
- data/README.md +3 -1
- data/Rakefile +13 -2
- data/bin/md2html +96 -19
- data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
- data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
- data/lib/geneva_drive/combined_exception_policy.rb +106 -0
- data/lib/geneva_drive/exception_policy.rb +354 -0
- data/lib/geneva_drive/executor.rb +430 -148
- data/lib/geneva_drive/flow_control.rb +82 -11
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
- data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
- data/lib/geneva_drive/migration_helpers.rb +55 -20
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/step_definition.rb +160 -40
- data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
- data/lib/geneva_drive/step_execution.rb +124 -2
- data/lib/geneva_drive/test_helpers.rb +123 -4
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +485 -73
- data/lib/geneva_drive.rb +100 -2
- data/test/dsl/class_level_on_exception_test.rb +200 -0
- data/test/dsl/exception_policy_test.rb +331 -0
- data/test/dsl/step_definition_test.rb +126 -2
- data/test/dsl/step_level_exception_policy_test.rb +143 -0
- data/test/dummy/config/storage.yml +7 -0
- data/test/dummy_install/config/storage.yml +7 -0
- data/test/jobs/housekeeping_job_test.rb +451 -5
- data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +137 -0
- data/test/step_execution/metadata_accessor_test.rb +147 -0
- data/test/test_helper.rb +37 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
- data/test/workflow/composable_exception_policy_test.rb +231 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/executor_test.rb +100 -4
- data/test/workflow/external_cancel_test.rb +245 -0
- data/test/workflow/flow_control_test.rb +4 -3
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/max_reattempts_test.rb +51 -0
- data/test/workflow/pause_resume_test.rb +574 -0
- data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/resume_and_skip_test.rb +57 -41
- data/test/workflow/with_inline_enqueue_test.rb +137 -0
- data/test/workflow/workflow_test.rb +117 -2
- metadata +47 -61
- data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
- data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
- data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/schema.rb +0 -72
- data/test/dummy/log/development.log +0 -112
- data/test/dummy/log/test.log +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
- data/test/dummy_install/Rakefile +0 -6
- data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
- data/test/dummy_install/app/controllers/application_controller.rb +0 -4
- data/test/dummy_install/app/helpers/application_helper.rb +0 -2
- data/test/dummy_install/app/jobs/application_job.rb +0 -7
- data/test/dummy_install/app/models/application_record.rb +0 -3
- data/test/dummy_install/app/models/user.rb +0 -9
- data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
- data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
- data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
- data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
- data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
- data/test/dummy_install/bin/dev +0 -2
- data/test/dummy_install/bin/rails +0 -4
- data/test/dummy_install/bin/rake +0 -4
- data/test/dummy_install/bin/setup +0 -34
- data/test/dummy_install/config/application.rb +0 -29
- data/test/dummy_install/config/boot.rb +0 -5
- data/test/dummy_install/config/cable.yml +0 -10
- data/test/dummy_install/config/database.yml +0 -13
- data/test/dummy_install/config/environment.rb +0 -5
- data/test/dummy_install/config/environments/development.rb +0 -51
- data/test/dummy_install/config/environments/production.rb +0 -70
- data/test/dummy_install/config/environments/test.rb +0 -39
- data/test/dummy_install/config/initializers/assets.rb +0 -7
- data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
- data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
- data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
- data/test/dummy_install/config/initializers/inflections.rb +0 -16
- data/test/dummy_install/config/locales/en.yml +0 -31
- data/test/dummy_install/config/puma.rb +0 -38
- data/test/dummy_install/config/routes.rb +0 -3
- data/test/dummy_install/config.ru +0 -6
- data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
- data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
- data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/schema.rb +0 -76
- data/test/dummy_install/log/test.log +0 -4031
- data/test/dummy_install/public/400.html +0 -114
- data/test/dummy_install/public/404.html +0 -114
- data/test/dummy_install/public/406-unsupported-browser.html +0 -114
- data/test/dummy_install/public/422.html +0 -114
- data/test/dummy_install/public/500.html +0 -114
- data/test/dummy_install/public/icon.png +0 -0
- data/test/dummy_install/public/icon.svg +0 -3
- data/test/dummy_install/tmp/local_secret.txt +0 -1
- data/test/generators/install_generator_test.rb +0 -94
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Wraps an array of {ExceptionPolicy} objects into a single composable unit.
|
|
4
|
+
#
|
|
5
|
+
# Created automatically when a step's +on_exception:+ receives an Array of
|
|
6
|
+
# {ExceptionPolicy} objects. You never need to instantiate this class directly;
|
|
7
|
+
# it is produced by {StepDefinition} during validation. The executor also builds
|
|
8
|
+
# a CombinedExceptionPolicy at resolution time by composing step-level,
|
|
9
|
+
# class-level, and default policies into one stack.
|
|
10
|
+
#
|
|
11
|
+
# ## Resolution order
|
|
12
|
+
#
|
|
13
|
+
# {#apply} walks the constituent policies in order. The first policy whose
|
|
14
|
+
# {ExceptionPolicy#captures? captures?} returns +true+ for the error wins.
|
|
15
|
+
# Ordering determines precedence: specific policies (those with +matching:+)
|
|
16
|
+
# should be listed before blanket policies, like +rescue+ clauses in Ruby.
|
|
17
|
+
#
|
|
18
|
+
# If no policy captures the error, {#apply} returns +nil+.
|
|
19
|
+
#
|
|
20
|
+
# ## Global reattempt cap
|
|
21
|
+
#
|
|
22
|
+
# {#max_reattempts} returns the *minimum* +max_reattempts+ value across all
|
|
23
|
+
# constituent policies (ignoring +nil+, which means unlimited). When {#apply}
|
|
24
|
+
# delegates to a child that wants to reattempt, it checks this global cap
|
|
25
|
+
# first. If the cap is exceeded, the matched policy's +terminal_action+ is
|
|
26
|
+
# applied instead. This prevents runaway retries when different exception
|
|
27
|
+
# types alternate.
|
|
28
|
+
#
|
|
29
|
+
# @example Policies defined on a step
|
|
30
|
+
# step :sync, on_exception: [
|
|
31
|
+
# ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, max_reattempts: 10),
|
|
32
|
+
# ExceptionPolicy.new(:cancel!, matching: FatalApiError),
|
|
33
|
+
# ExceptionPolicy.new(:skip!) # blanket fallback
|
|
34
|
+
# ] do
|
|
35
|
+
# ExternalApi.sync(hero)
|
|
36
|
+
# end
|
|
37
|
+
# # StepDefinition wraps this array in a CombinedExceptionPolicy automatically.
|
|
38
|
+
# # - Timeout::Error -> reattempt (up to 10 times)
|
|
39
|
+
# # - FatalApiError -> cancel
|
|
40
|
+
# # - anything else -> skip
|
|
41
|
+
# # - global cap -> 10 (the only finite max_reattempts in the array)
|
|
42
|
+
#
|
|
43
|
+
# @see ExceptionPolicy
|
|
44
|
+
# @see StepDefinition#exception_policy
|
|
45
|
+
# @api private
|
|
46
|
+
class GenevaDrive::CombinedExceptionPolicy
|
|
47
|
+
# @return [Array<GenevaDrive::ExceptionPolicy>] the constituent policies
|
|
48
|
+
attr_reader :policies
|
|
49
|
+
|
|
50
|
+
# @param policies [Array<GenevaDrive::ExceptionPolicy>] policies to combine
|
|
51
|
+
def initialize(policies)
|
|
52
|
+
@policies = Array(policies)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns true if any constituent policy captures the given error.
|
|
56
|
+
#
|
|
57
|
+
# @param error [Exception]
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def captures?(error)
|
|
60
|
+
@policies.any? { |policy| policy.captures?(error) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
alias_method :matches?, :captures?
|
|
64
|
+
|
|
65
|
+
# Applies the first matching policy to the given error.
|
|
66
|
+
#
|
|
67
|
+
# Walks policies in order, delegating to the first one that captures the
|
|
68
|
+
# error. Before delegating a reattempt, checks the global cap
|
|
69
|
+
# ({#max_reattempts}) and overrides with the matched policy's
|
|
70
|
+
# +terminal_action+ if exceeded.
|
|
71
|
+
#
|
|
72
|
+
# @param error [Exception] the exception that was raised
|
|
73
|
+
# @param reattempt_count [Integer] consecutive reattempts so far
|
|
74
|
+
# @param workflow [GenevaDrive::Workflow] the workflow instance
|
|
75
|
+
# @return [Hash, nil] result Hash (see {ExceptionPolicy#apply}), or nil if
|
|
76
|
+
# no policy captures the error
|
|
77
|
+
#
|
|
78
|
+
# @see ExceptionPolicy#apply
|
|
79
|
+
def apply(error, reattempt_count:, workflow:)
|
|
80
|
+
child = @policies.find { |p| p.captures?(error) }
|
|
81
|
+
return nil unless child
|
|
82
|
+
|
|
83
|
+
result = child.apply(error, reattempt_count: reattempt_count, workflow: workflow)
|
|
84
|
+
|
|
85
|
+
# Enforce global reattempt cap (min max_reattempts across all children).
|
|
86
|
+
# If the matched policy wants to reattempt but the global cap is exceeded,
|
|
87
|
+
# override with the matched policy's terminal_action.
|
|
88
|
+
cap = max_reattempts
|
|
89
|
+
if cap && result[:action] == :reattempt && reattempt_count >= cap
|
|
90
|
+
terminal = child.terminal_action.to_s.chomp("!").to_sym
|
|
91
|
+
return {action: terminal, error: error, report: result[:report], terminal: true}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the effective global reattempt cap: the minimum max_reattempts
|
|
98
|
+
# across all constituent policies (ignoring nil = unlimited).
|
|
99
|
+
# Returns nil if all policies have unlimited reattempts.
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer, nil]
|
|
102
|
+
def max_reattempts
|
|
103
|
+
caps = @policies.filter_map(&:max_reattempts)
|
|
104
|
+
caps.empty? ? nil : caps.min
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundles exception handling configuration into a reusable object.
|
|
4
|
+
# Can be used at both class level (via `on_exception`) and step level
|
|
5
|
+
# (via the `on_exception:` keyword argument).
|
|
6
|
+
#
|
|
7
|
+
# Supports two mutually exclusive modes:
|
|
8
|
+
#
|
|
9
|
+
# **Declarative mode** — specify an action symbol and options:
|
|
10
|
+
# ExceptionPolicy.new(:reattempt!, wait: 15.seconds, max_reattempts: 5)
|
|
11
|
+
#
|
|
12
|
+
# **Imperative mode** — provide a block that receives the exception
|
|
13
|
+
# and calls flow control methods in the workflow context:
|
|
14
|
+
# ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }
|
|
15
|
+
#
|
|
16
|
+
# Policies can optionally target specific exception classes via the +matching:+
|
|
17
|
+
# keyword. A policy without +matching:+ is a "blanket" policy that matches any
|
|
18
|
+
# exception. A policy with +matching:+ is a "specific" policy that only fires
|
|
19
|
+
# for errors matching the given class(es).
|
|
20
|
+
#
|
|
21
|
+
# Multiple policies can be composed into an array and passed to a step's
|
|
22
|
+
# +on_exception:+ option. GenevaDrive wraps the array in a
|
|
23
|
+
# {CombinedExceptionPolicy} that walks specific policies first, then falls back
|
|
24
|
+
# to the first blanket policy. If no policy in the array matches, resolution
|
|
25
|
+
# continues at the class level.
|
|
26
|
+
#
|
|
27
|
+
# @example Declarative policy with exception matching
|
|
28
|
+
# ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5)
|
|
29
|
+
#
|
|
30
|
+
# @example Composing multiple policies for a step
|
|
31
|
+
# step :sync, on_exception: [
|
|
32
|
+
# ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, max_reattempts: 10),
|
|
33
|
+
# ExceptionPolicy.new(:cancel!, matching: OAuth2::Error),
|
|
34
|
+
# ExceptionPolicy.new(:skip!) # blanket fallback
|
|
35
|
+
# ] do
|
|
36
|
+
# ExternalApi.sync(hero)
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# @see CombinedExceptionPolicy
|
|
40
|
+
# @api public
|
|
41
|
+
class GenevaDrive::ExceptionPolicy
|
|
42
|
+
# Matches exceptions by class name without requiring the constant to be
|
|
43
|
+
# loaded at definition time. The class lookup happens lazily on each #===
|
|
44
|
+
# call. If the constant cannot be resolved, the matcher returns false
|
|
45
|
+
# (no match) rather than raising.
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# on_exception "SomeLib::TransientError", action: :reattempt!
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
class LazyExceptionMatcher
|
|
52
|
+
# @param class_name [String] fully-qualified exception class name
|
|
53
|
+
def initialize(class_name)
|
|
54
|
+
@class_name = class_name.to_s.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param error [Exception]
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def ===(error)
|
|
60
|
+
klass = @class_name.safe_constantize
|
|
61
|
+
klass ? error.is_a?(klass) : false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<LazyExceptionMatcher #{@class_name}>"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Valid action values (same as StepDefinition::EXCEPTION_HANDLERS)
|
|
70
|
+
VALID_ACTIONS = %i[pause! cancel! reattempt! skip!].freeze
|
|
71
|
+
|
|
72
|
+
# @return [Symbol, nil] the action (:pause!, :cancel!, :reattempt!, :skip!) — nil in imperative mode
|
|
73
|
+
attr_reader :action
|
|
74
|
+
|
|
75
|
+
# @return [ActiveSupport::Duration, nil] wait time before reattempt
|
|
76
|
+
attr_reader :wait
|
|
77
|
+
|
|
78
|
+
# @return [Integer, nil] maximum consecutive reattempts before pausing (nil = unlimited)
|
|
79
|
+
attr_reader :max_reattempts
|
|
80
|
+
|
|
81
|
+
# @return [Symbol] what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)
|
|
82
|
+
attr_reader :terminal_action
|
|
83
|
+
|
|
84
|
+
# @return [Array<#===>] exception matchers this policy checks (empty = match all).
|
|
85
|
+
# Each entry can be an Exception subclass or any object responding to #===.
|
|
86
|
+
attr_reader :exception_matchers
|
|
87
|
+
|
|
88
|
+
# @return [Symbol] when to report the exception via Rails.error.report (:always, :never, or :terminal_only)
|
|
89
|
+
attr_reader :report
|
|
90
|
+
|
|
91
|
+
# @return [Proc, nil] the handler block (imperative mode)
|
|
92
|
+
attr_reader :handler
|
|
93
|
+
|
|
94
|
+
# Creates a new exception policy.
|
|
95
|
+
#
|
|
96
|
+
# Valid terminal_action values
|
|
97
|
+
VALID_TERMINAL_ACTIONS = %i[pause! cancel! skip!].freeze
|
|
98
|
+
|
|
99
|
+
# Valid report values
|
|
100
|
+
VALID_REPORT_OPTIONS = %i[always never terminal_only].freeze
|
|
101
|
+
|
|
102
|
+
# @overload initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always)
|
|
103
|
+
# Declarative mode — specify action and options.
|
|
104
|
+
# @param action [Symbol] the flow control action (:pause!, :cancel!, :reattempt!, :skip!)
|
|
105
|
+
# @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
|
|
106
|
+
# this policy applies to. When +nil+ (the default), the policy matches any exception
|
|
107
|
+
# (blanket policy). When set, only errors matching the given class(es) trigger this policy
|
|
108
|
+
# (specific policy). Strings are resolved lazily via +safe_constantize+, so the exception
|
|
109
|
+
# class does not need to be loaded at definition time.
|
|
110
|
+
# @param wait [ActiveSupport::Duration, nil] wait time before reattempt
|
|
111
|
+
# @param max_reattempts [Integer, nil] max consecutive reattempts (nil = unlimited)
|
|
112
|
+
# @param terminal_action [Symbol] what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)
|
|
113
|
+
# @param report [Symbol] when to report the exception to +Rails.error.report+.
|
|
114
|
+
# - +:always+ (default) — report every exception regardless of what action is taken
|
|
115
|
+
# - +:never+ — never report; the exception is expected and handled by the policy
|
|
116
|
+
# - +:terminal_only+ — suppress reports while the step is being reattempted, but
|
|
117
|
+
# report when reattempts are exhausted and the +terminal_action+ fires
|
|
118
|
+
#
|
|
119
|
+
# @overload initialize(matching: nil, report: :always, &block)
|
|
120
|
+
# Imperative mode — block receives exception, runs in workflow context.
|
|
121
|
+
# Must call a flow control method (reattempt!, cancel!, pause!, skip!).
|
|
122
|
+
# Can be combined with +matching:+ and +report:+ to target specific exception classes
|
|
123
|
+
# and control error reporting.
|
|
124
|
+
# @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
|
|
125
|
+
# @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
|
|
126
|
+
# @yield [error] the exception that was raised
|
|
127
|
+
#
|
|
128
|
+
# @example Blanket reattempt policy
|
|
129
|
+
# ExceptionPolicy.new(:reattempt!, wait: 30.seconds, max_reattempts: 5)
|
|
130
|
+
#
|
|
131
|
+
# @example Specific policy matching a single exception class
|
|
132
|
+
# ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 10)
|
|
133
|
+
#
|
|
134
|
+
# @example Specific policy matching multiple exception classes
|
|
135
|
+
# ExceptionPolicy.new(:cancel!, matching: [OAuth2::Error, "Faraday::ConnectionFailed"])
|
|
136
|
+
#
|
|
137
|
+
# @example Imperative policy
|
|
138
|
+
# ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }
|
|
139
|
+
#
|
|
140
|
+
# @example Imperative policy with exception matching
|
|
141
|
+
# ExceptionPolicy.new(matching: Timeout::Error) { |error| reattempt!(wait: error.retry_after) }
|
|
142
|
+
#
|
|
143
|
+
# @example Suppress error reporting for expected exceptions
|
|
144
|
+
# ExceptionPolicy.new(:reattempt!, matching: RateLimitError, report: :never)
|
|
145
|
+
#
|
|
146
|
+
# @example Only report when reattempts are exhausted
|
|
147
|
+
# ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5, report: :terminal_only)
|
|
148
|
+
def initialize(action = nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, matching: nil, report: :always, &block)
|
|
149
|
+
@report = report
|
|
150
|
+
unless VALID_REPORT_OPTIONS.include?(@report)
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"report: must be one of #{VALID_REPORT_OPTIONS.join(", ")}, got #{@report.inspect}"
|
|
153
|
+
end
|
|
154
|
+
if block
|
|
155
|
+
if action || wait || max_reattempts || terminal_action != :pause!
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
"Cannot pass action, wait, max_reattempts, or terminal_action when a block is given"
|
|
158
|
+
end
|
|
159
|
+
@handler = block
|
|
160
|
+
@action = nil
|
|
161
|
+
@wait = nil
|
|
162
|
+
@max_reattempts = nil
|
|
163
|
+
@terminal_action = :pause!
|
|
164
|
+
else
|
|
165
|
+
raise ArgumentError, "Either an action or a block is required" unless action
|
|
166
|
+
@handler = nil
|
|
167
|
+
@action = action
|
|
168
|
+
@wait = wait
|
|
169
|
+
@max_reattempts = max_reattempts
|
|
170
|
+
@terminal_action = terminal_action
|
|
171
|
+
validate!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
@exception_matchers = build_matchers(matching)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns true if this is a declarative policy (action symbol, no block).
|
|
178
|
+
#
|
|
179
|
+
# @return [Boolean]
|
|
180
|
+
def declarative?
|
|
181
|
+
handler.nil?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Returns true if this policy captures the given error.
|
|
185
|
+
# A policy with no exception matchers captures all errors (blanket policy).
|
|
186
|
+
# A policy with matchers only captures errors matching the given class(es).
|
|
187
|
+
#
|
|
188
|
+
# @param error [Exception] the exception to check
|
|
189
|
+
# @return [Boolean]
|
|
190
|
+
def captures?(error)
|
|
191
|
+
exception_matchers.empty? || exception_matchers.any? { |matcher| matcher === error }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
alias_method :matches?, :captures?
|
|
195
|
+
|
|
196
|
+
# Returns true if this policy has exception matchers.
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean]
|
|
199
|
+
def specific?
|
|
200
|
+
exception_matchers.any?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns true if this is a blanket policy (no exception matchers).
|
|
204
|
+
#
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def blanket?
|
|
207
|
+
exception_matchers.empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Applies this policy to the given error and returns a result Hash describing
|
|
211
|
+
# the action to take.
|
|
212
|
+
#
|
|
213
|
+
# For declarative policies, checks the reattempt limit and returns the
|
|
214
|
+
# appropriate action. For imperative policies, executes the handler block
|
|
215
|
+
# in the workflow context and translates the resulting flow control signal
|
|
216
|
+
# into a result.
|
|
217
|
+
#
|
|
218
|
+
# The returned Hash always contains +:action+ (Symbol without +!+),
|
|
219
|
+
# +:error+ (the original exception), and +:report+ (the reporting mode).
|
|
220
|
+
# Reattempt results also include +:wait+. When the terminal action fires
|
|
221
|
+
# (reattempt limit exceeded), +:terminal+ is set to +true+.
|
|
222
|
+
#
|
|
223
|
+
# @param error [Exception] the exception that was raised
|
|
224
|
+
# @param reattempt_count [Integer] consecutive reattempts so far for this step
|
|
225
|
+
# @param workflow [GenevaDrive::Workflow] the workflow instance (needed for imperative handlers)
|
|
226
|
+
# @return [Hash] result with +:action+, +:error+, and optionally +:wait+ keys
|
|
227
|
+
#
|
|
228
|
+
# @example Declarative policy result
|
|
229
|
+
# policy = ExceptionPolicy.new(:reattempt!, wait: 5.seconds, max_reattempts: 3)
|
|
230
|
+
# policy.apply(error, reattempt_count: 0, workflow: wf)
|
|
231
|
+
# # => { action: :reattempt, wait: 5.seconds, error: error }
|
|
232
|
+
#
|
|
233
|
+
# @see CombinedExceptionPolicy#apply
|
|
234
|
+
def apply(error, reattempt_count:, workflow:)
|
|
235
|
+
if handler
|
|
236
|
+
apply_imperative(error, workflow)
|
|
237
|
+
else
|
|
238
|
+
apply_declarative(error, reattempt_count)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Returns this policy wrapped in an Array for uniform iteration.
|
|
243
|
+
#
|
|
244
|
+
# {CombinedExceptionPolicy} stores its children in an Array via {#policies}.
|
|
245
|
+
# This method lets callers use +exception_policy.policies+ on either type
|
|
246
|
+
# without branching, producing a flat list of leaf policies.
|
|
247
|
+
#
|
|
248
|
+
# @return [Array<GenevaDrive::ExceptionPolicy>]
|
|
249
|
+
# @see CombinedExceptionPolicy#policies
|
|
250
|
+
def policies
|
|
251
|
+
[self]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
# Applies an imperative policy by executing the handler block in the workflow
|
|
257
|
+
# context. The block must call a flow control method (reattempt!, cancel!, etc.).
|
|
258
|
+
# If it doesn't, defaults to :pause.
|
|
259
|
+
#
|
|
260
|
+
# @param error [Exception]
|
|
261
|
+
# @param workflow [GenevaDrive::Workflow]
|
|
262
|
+
# @return [Hash]
|
|
263
|
+
def apply_imperative(error, workflow)
|
|
264
|
+
signal = catch(:flow_control) do
|
|
265
|
+
workflow.instance_exec(error, &handler)
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if signal.is_a?(GenevaDrive::FlowControlSignal)
|
|
270
|
+
{action: signal.action, wait: signal.options[:wait], error: error, report: @report}
|
|
271
|
+
else
|
|
272
|
+
{action: :pause, error: error, report: @report}
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Applies a declarative policy by checking the reattempt limit and returning
|
|
277
|
+
# the appropriate action.
|
|
278
|
+
#
|
|
279
|
+
# @param error [Exception]
|
|
280
|
+
# @param reattempt_count [Integer]
|
|
281
|
+
# @return [Hash]
|
|
282
|
+
def apply_declarative(error, reattempt_count)
|
|
283
|
+
if action == :reattempt! && max_reattempts && reattempt_count >= max_reattempts
|
|
284
|
+
terminal = terminal_action.to_s.chomp("!").to_sym
|
|
285
|
+
{action: terminal, error: error, report: @report, terminal: true}
|
|
286
|
+
else
|
|
287
|
+
{action: action.to_s.chomp("!").to_sym, wait: wait, error: error, report: @report}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Builds an array of exception matchers from the matching: argument.
|
|
292
|
+
# Strings become LazyExceptionMatcher, classes must be Exception subclasses,
|
|
293
|
+
# and anything else must respond to #===.
|
|
294
|
+
#
|
|
295
|
+
# @param raw [Class, String, #===, Array, nil]
|
|
296
|
+
# @return [Array<#===>]
|
|
297
|
+
def build_matchers(raw)
|
|
298
|
+
return [] if raw.nil?
|
|
299
|
+
|
|
300
|
+
Array(raw).map do |matcher|
|
|
301
|
+
if matcher.is_a?(String)
|
|
302
|
+
LazyExceptionMatcher.new(matcher)
|
|
303
|
+
elsif matcher.is_a?(Class)
|
|
304
|
+
unless matcher <= Exception
|
|
305
|
+
raise ArgumentError,
|
|
306
|
+
"Expected an Exception subclass, got #{matcher.inspect}"
|
|
307
|
+
end
|
|
308
|
+
matcher
|
|
309
|
+
elsif matcher.respond_to?(:===)
|
|
310
|
+
matcher
|
|
311
|
+
else
|
|
312
|
+
raise ArgumentError,
|
|
313
|
+
"Expected an exception matcher (Exception subclass, String, or object responding to #===), got #{matcher.inspect}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Validates declarative mode configuration.
|
|
319
|
+
#
|
|
320
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
321
|
+
def validate!
|
|
322
|
+
unless VALID_ACTIONS.include?(@action)
|
|
323
|
+
raise ArgumentError,
|
|
324
|
+
"Invalid action #{@action.inspect}: must be one of #{VALID_ACTIONS.join(", ")}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if @wait && @action != :reattempt!
|
|
328
|
+
raise ArgumentError,
|
|
329
|
+
"wait: only makes sense with action: :reattempt!"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
if @max_reattempts
|
|
333
|
+
unless @action == :reattempt!
|
|
334
|
+
raise ArgumentError,
|
|
335
|
+
"max_reattempts: only makes sense with action: :reattempt!"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
unless @max_reattempts.is_a?(Integer) && @max_reattempts > 0
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"max_reattempts: must be a positive integer or nil"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
unless VALID_TERMINAL_ACTIONS.include?(@terminal_action)
|
|
345
|
+
raise ArgumentError,
|
|
346
|
+
"terminal_action: must be one of #{VALID_TERMINAL_ACTIONS.join(", ")}, got #{@terminal_action.inspect}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
if @terminal_action != :pause! && @action != :reattempt!
|
|
350
|
+
raise ArgumentError,
|
|
351
|
+
"terminal_action: only makes sense with action: :reattempt!"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|