dexkit 0.6.0 → 0.8.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.
@@ -5,15 +5,48 @@ module Dex
5
5
  class Handler
6
6
  include Dex::Executable
7
7
 
8
+ extend Registry
9
+
10
+ def self.deregister(klass)
11
+ if klass.respond_to?(:handled_events)
12
+ klass.handled_events.each { |ec| Bus.unsubscribe(ec, klass) }
13
+ end
14
+ super
15
+ end
16
+
8
17
  attr_reader :event
9
18
 
10
19
  def self.on(*event_classes)
11
20
  event_classes.each do |ec|
12
21
  Event.validate_event_class!(ec)
13
22
  Bus.subscribe(ec, self)
23
+ (@_handled_events ||= []) << ec
14
24
  end
15
25
  end
16
26
 
27
+ def self.handled_events
28
+ defined?(@_handled_events) ? @_handled_events.dup.freeze : [].freeze
29
+ end
30
+
31
+ def self.to_h
32
+ h = {}
33
+ h[:name] = name if name
34
+ event_names = handled_events.filter_map(&:name)
35
+ h[:events] = event_names unless event_names.empty?
36
+ retry_config = _event_handler_retry_config
37
+ h[:retries] = retry_config[:count] if retry_config
38
+ tx_s = settings_for(:transaction)
39
+ h[:transaction] = tx_s.fetch(:enabled, false)
40
+ h[:pipeline] = pipeline.steps.map(&:name)
41
+ h
42
+ end
43
+
44
+ def self.export(format: :hash)
45
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash" unless format == :hash
46
+
47
+ registry.sort_by(&:name).map(&:to_h)
48
+ end
49
+
17
50
  def self.retries(count, **opts)
18
51
  raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
19
52
 
data/lib/dex/event.rb CHANGED
@@ -15,6 +15,33 @@ module Dex
15
15
 
16
16
  include PropsSetup
17
17
  include TypeCoercion
18
+ include ContextSetup
19
+
20
+ extend Registry
21
+
22
+ class << self
23
+ def to_h
24
+ Export.build_hash(self)
25
+ end
26
+
27
+ def to_json_schema
28
+ Export.build_json_schema(self)
29
+ end
30
+
31
+ def export(format: :hash)
32
+ unless %i[hash json_schema].include?(format)
33
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
34
+ end
35
+
36
+ sorted = registry.sort_by(&:name)
37
+ sorted.map do |klass|
38
+ case format
39
+ when :hash then klass.to_h
40
+ when :json_schema then klass.to_json_schema
41
+ end
42
+ end
43
+ end
44
+ end
18
45
 
19
46
  def self._warn(message)
20
47
  Dex.warn("Event: #{message}")
@@ -83,3 +110,4 @@ end
83
110
  require_relative "event/bus"
84
111
  require_relative "event/handler"
85
112
  require_relative "event/processor"
113
+ require_relative "event/export"
@@ -21,7 +21,9 @@ module Dex
21
21
 
22
22
  def enqueue_direct_job
23
23
  job = apply_options(Operation::DirectJob)
24
- job.perform_later(class_name: operation_class_name, params: serialized_params)
24
+ payload = { class_name: operation_class_name, params: serialized_params }
25
+ apply_once_payload!(payload)
26
+ job.perform_later(**payload)
25
27
  end
26
28
 
27
29
  def enqueue_record_job
@@ -32,7 +34,9 @@ module Dex
32
34
  )
33
35
  begin
34
36
  job = apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: operation_class_name, record_id: record.id.to_s)
37
+ payload = { class_name: operation_class_name, record_id: record.id.to_s }
38
+ apply_once_payload!(payload)
39
+ job.perform_later(**payload)
36
40
  rescue => e
37
41
  begin
38
42
  record.destroy
@@ -68,6 +72,18 @@ module Dex
68
72
  raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
73
  end
70
74
 
75
+ def apply_once_payload!(payload)
76
+ return unless @operation.instance_variable_defined?(:@_once_key_explicit) &&
77
+ @operation.instance_variable_get(:@_once_key_explicit)
78
+
79
+ once_key = @operation.instance_variable_get(:@_once_key)
80
+ if once_key
81
+ payload[:once_key] = once_key
82
+ else
83
+ payload[:once_bypass] = true
84
+ end
85
+ end
86
+
71
87
  def merged_options
72
88
  @operation.class.settings_for(:async).merge(@runtime_options)
73
89
  end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module Explain
6
+ def explain(**kwargs)
7
+ error = nil
8
+ instance = begin
9
+ new(**kwargs)
10
+ rescue Literal::TypeError, ArgumentError => e
11
+ error = e
12
+ nil
13
+ end
14
+
15
+ info = {}
16
+ active = pipeline.steps.map(&:name).to_set
17
+
18
+ info[:operation] = name || "(anonymous)"
19
+ desc = description
20
+ info[:description] = desc if desc
21
+ info[:error] = "#{error.class}: #{error.message}" if error
22
+ info[:props] = _explain_props(instance)
23
+ info[:context] = _explain_context(instance, kwargs)
24
+ info[:guards] = active.include?(:guard) ? _explain_guards(instance) : { passed: true, results: [] }
25
+ info[:once] = active.include?(:once) ? _explain_once(instance) : { active: false }
26
+ info[:lock] = active.include?(:lock) ? _explain_lock(instance) : { active: false }
27
+ info[:record] = active.include?(:record) ? _explain_record : { enabled: false }
28
+ info[:transaction] = active.include?(:transaction) ? _explain_transaction : { enabled: false }
29
+ info[:rescue_from] = active.include?(:rescue) ? _explain_rescue : {}
30
+ info[:callbacks] = active.include?(:callback) ? _explain_callbacks : { before: 0, after: 0, around: 0 }
31
+
32
+ if instance
33
+ pipeline.steps.each do |step|
34
+ method_name = :"_#{step.name}_explain"
35
+ send(method_name, instance, info) if respond_to?(method_name, true)
36
+ end
37
+ end
38
+
39
+ info[:pipeline] = pipeline.steps.map(&:name)
40
+ info[:callable] = instance ? _explain_callable?(info) : false
41
+ info.freeze
42
+ end
43
+
44
+ private
45
+
46
+ def _explain_callable?(info)
47
+ return false unless info[:guards][:passed]
48
+
49
+ if info[:once][:active]
50
+ return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
51
+ end
52
+
53
+ true
54
+ end
55
+
56
+ ONCE_BLOCKING_STATUSES = %i[invalid pending misconfigured unavailable].freeze
57
+
58
+ def _explain_props(instance)
59
+ return {} unless respond_to?(:literal_properties)
60
+ return {} unless instance
61
+
62
+ literal_properties.each_with_object({}) do |prop, hash|
63
+ hash[prop.name] = instance.public_send(prop.name)
64
+ end
65
+ end
66
+
67
+ def _explain_context(instance, explicit_kwargs)
68
+ mappings = respond_to?(:context_mappings) ? context_mappings : {}
69
+ return { resolved: {}, mappings: {}, source: {} } if mappings.empty?
70
+
71
+ ambient = Dex.context
72
+ resolved = {}
73
+ source = {}
74
+
75
+ mappings.each do |prop_name, context_key|
76
+ resolved[prop_name] = instance.public_send(prop_name) if instance
77
+ source[prop_name] = if explicit_kwargs.key?(prop_name)
78
+ :explicit
79
+ elsif ambient.key?(context_key)
80
+ :ambient
81
+ elsif instance || _explain_prop_has_default?(prop_name)
82
+ :default
83
+ else
84
+ :missing
85
+ end
86
+ end
87
+
88
+ { resolved: resolved, mappings: mappings, source: source }
89
+ end
90
+
91
+ def _explain_guards(instance)
92
+ return { passed: false, results: [] } unless instance
93
+
94
+ all_results = instance.send(:_guard_evaluate_all)
95
+ results = all_results.map do |r|
96
+ entry = { name: r[:name], passed: r[:passed] }
97
+ entry[:message] = r[:message] if r[:message]
98
+ entry[:skipped] = true if r[:skipped]
99
+ entry
100
+ end
101
+ { passed: results.all? { |r| r[:passed] }, results: results }
102
+ end
103
+
104
+ def _explain_once(instance)
105
+ settings = settings_for(:once)
106
+ return { active: false } unless settings.fetch(:defined, false)
107
+
108
+ key = instance&.send(:_once_derive_key)
109
+ {
110
+ active: true,
111
+ key: key,
112
+ status: _explain_once_status(key),
113
+ expires_in: settings[:expires_in]
114
+ }
115
+ end
116
+
117
+ def _explain_once_status(key)
118
+ return :invalid if key.nil?
119
+ return :misconfigured if name.nil?
120
+ return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
121
+ return :unavailable unless Dex.record_backend
122
+ return :misconfigured unless Dex.record_backend.has_field?("once_key")
123
+
124
+ settings = settings_for(:once)
125
+ if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
+ return :misconfigured
127
+ end
128
+
129
+ existing = Dex.record_backend.find_by_once_key(key)
130
+ return :exists if existing
131
+
132
+ if Dex.record_backend.has_field?("once_key_expires_at")
133
+ expired = Dex.record_backend.find_expired_once_key(key)
134
+ return :expired if expired
135
+ end
136
+
137
+ pending = Dex.record_backend.find_pending_once_key(key)
138
+ return :pending if pending
139
+
140
+ :fresh
141
+ end
142
+
143
+ def _explain_lock(instance)
144
+ settings = settings_for(:advisory_lock)
145
+ return { active: false } unless settings.fetch(:enabled, false)
146
+
147
+ key = if instance
148
+ instance.send(:_lock_key)
149
+ else
150
+ case settings[:key]
151
+ when String then settings[:key]
152
+ when nil then name
153
+ end
154
+ end
155
+
156
+ { active: true, key: key, timeout: settings[:timeout] }
157
+ end
158
+
159
+ def _explain_prop_has_default?(prop_name)
160
+ return false unless respond_to?(:literal_properties)
161
+
162
+ literal_properties.any? { |p| p.name == prop_name && p.default? }
163
+ end
164
+
165
+ def _explain_record
166
+ settings = settings_for(:record)
167
+ enabled = settings.fetch(:enabled, true) && !!Dex.record_backend && !!name
168
+ return { enabled: false } unless enabled
169
+
170
+ {
171
+ enabled: true,
172
+ params: settings.fetch(:params, true),
173
+ result: settings.fetch(:result, true)
174
+ }
175
+ end
176
+
177
+ def _explain_transaction
178
+ settings = settings_for(:transaction)
179
+ return { enabled: false } unless settings.fetch(:enabled, true)
180
+
181
+ adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
182
+ adapter = Operation::TransactionAdapter.for(adapter_name)
183
+ { enabled: !adapter.nil? }
184
+ end
185
+
186
+ def _explain_rescue
187
+ handlers = respond_to?(:_rescue_handlers) ? _rescue_handlers : []
188
+ handlers.each_with_object({}) do |h, hash|
189
+ hash[h[:exception_class].name] = h[:code]
190
+ end
191
+ end
192
+
193
+ def _explain_callbacks
194
+ return { before: 0, after: 0, around: 0 } unless respond_to?(:_callback_list)
195
+
196
+ {
197
+ before: _callback_list(:before).size,
198
+ after: _callback_list(:after).size,
199
+ around: _callback_list(:around).size
200
+ }
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module Export
6
+ module_function
7
+
8
+ def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
9
+ h = {}
10
+ h[:name] = source.name if source&.name
11
+ desc = source&.description
12
+ h[:description] = desc if desc
13
+ h[:params] = _serialize_params(source, contract.params)
14
+ h[:success] = TypeSerializer.to_string(contract.success) if contract.success
15
+ h[:errors] = contract.errors unless contract.errors.empty?
16
+ h[:guards] = contract.guards unless contract.guards.empty?
17
+ ctx = _serialize_context(source)
18
+ h[:context] = ctx unless ctx.empty?
19
+ h[:pipeline] = source.pipeline.steps.map(&:name) if source
20
+ h[:settings] = _serialize_settings(source) if source
21
+ h
22
+ end
23
+
24
+ def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
25
+ case section
26
+ when :params then _params_schema(source, contract)
27
+ when :success then _success_schema(source, contract)
28
+ when :errors then _errors_schema(source, contract)
29
+ when :full then _full_schema(source, contract)
30
+ else
31
+ raise ArgumentError,
32
+ "unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
33
+ end
34
+ end
35
+
36
+ def _serialize_params(source, params)
37
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
38
+ params.each_with_object({}) do |(name, type), hash|
39
+ entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
40
+ entry[:desc] = descs[name] if descs[name]
41
+ hash[name] = entry
42
+ end
43
+ end
44
+
45
+ def _required?(source, prop_name)
46
+ return true unless source&.respond_to?(:literal_properties)
47
+
48
+ prop = source.literal_properties.find { |p| p.name == prop_name }
49
+ return true unless prop
50
+
51
+ prop.required?
52
+ end
53
+
54
+ def _serialize_context(source)
55
+ source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
56
+ end
57
+
58
+ def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
59
+ settings = {}
60
+
61
+ record_s = source.settings_for(:record)
62
+ settings[:record] = {
63
+ enabled: record_s.fetch(:enabled, true),
64
+ params: record_s.fetch(:params, true),
65
+ result: record_s.fetch(:result, true)
66
+ }
67
+
68
+ tx_s = source.settings_for(:transaction)
69
+ settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
70
+
71
+ once_s = source.settings_for(:once)
72
+ settings[:once] = { defined: once_s.fetch(:defined, false) }
73
+
74
+ settings
75
+ end
76
+
77
+ def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
78
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
79
+ properties = {}
80
+ required = []
81
+
82
+ contract.params.each do |name, type|
83
+ prop_desc = descs[name]
84
+ schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
85
+ properties[name.to_s] = schema
86
+ required << name.to_s if _required?(source, name)
87
+ end
88
+
89
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
90
+ result[:title] = source.name if source&.name
91
+ desc = source&.description
92
+ result[:description] = desc if desc
93
+ result[:properties] = properties unless properties.empty?
94
+ result[:required] = required unless required.empty?
95
+ result[:additionalProperties] = false
96
+ result
97
+ end
98
+
99
+ def _success_schema(source, contract)
100
+ return {} unless contract.success
101
+
102
+ schema = TypeSerializer.to_json_schema(contract.success)
103
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
104
+ result[:title] = "#{source&.name} success" if source&.name
105
+ result.merge(schema)
106
+ end
107
+
108
+ def _errors_schema(source, contract)
109
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
110
+ result[:title] = "#{source&.name} errors" if source&.name
111
+ result[:type] = "object"
112
+
113
+ properties = {}
114
+ contract.errors.each do |code|
115
+ properties[code.to_s] = {
116
+ type: "object",
117
+ properties: {
118
+ code: { const: code.to_s },
119
+ message: { type: "string" },
120
+ details: { type: "object" }
121
+ }
122
+ }
123
+ end
124
+ result[:properties] = properties unless properties.empty?
125
+ result
126
+ end
127
+
128
+ def _full_schema(source, contract)
129
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
130
+ result[:title] = source.name if source&.name
131
+ result[:description] = "Operation contract"
132
+ result[:properties] = {
133
+ params: _params_schema(source, contract).except(:$schema),
134
+ success: _success_schema(source, contract).except(:$schema),
135
+ errors: _errors_schema(source, contract).except(:$schema)
136
+ }
137
+ result
138
+ end
139
+
140
+ private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
141
+ :_params_schema, :_success_schema, :_errors_schema, :_full_schema
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module GuardWrapper
5
+ extend Dex::Concern
6
+
7
+ GuardDefinition = Data.define(:name, :message, :requires, :block)
8
+
9
+ module ClassMethods
10
+ def guard(code, message = nil, requires: nil, &block)
11
+ raise ArgumentError, "guard code must be a Symbol, got: #{code.inspect}" unless code.is_a?(Symbol)
12
+ raise ArgumentError, "guard requires a block" unless block
13
+
14
+ requires = _guard_normalize_requires!(code, requires)
15
+ _guard_validate_unique!(code)
16
+
17
+ _guard_own << GuardDefinition.new(name: code, message: message, requires: requires, block: block)
18
+
19
+ error(code) if respond_to?(:error)
20
+ end
21
+
22
+ def _guard_list
23
+ parent = superclass.respond_to?(:_guard_list) ? superclass._guard_list : []
24
+ parent + _guard_own
25
+ end
26
+
27
+ def callable(**kwargs)
28
+ instance = new(**kwargs)
29
+ failures = instance.send(:_guard_evaluate)
30
+ if failures.empty?
31
+ Operation::Ok.new(nil)
32
+ else
33
+ first = failures.first
34
+ error = Dex::Error.new(first[:guard], first[:message], details: failures)
35
+ Operation::Err.new(error)
36
+ end
37
+ end
38
+
39
+ def callable?(*args, **kwargs)
40
+ if args.size > 1
41
+ raise ArgumentError, "callable? accepts at most one guard name, got #{args.size} arguments"
42
+ end
43
+
44
+ if args.first
45
+ guard_name = args.first
46
+ unless guard_name.is_a?(Symbol)
47
+ raise ArgumentError, "guard name must be a Symbol, got: #{guard_name.inspect}"
48
+ end
49
+ unless _guard_list.any? { |g| g.name == guard_name }
50
+ raise ArgumentError, "unknown guard :#{guard_name}. Declared: #{_guard_list.map(&:name).map(&:inspect).join(", ")}"
51
+ end
52
+ instance = new(**kwargs)
53
+ failures = instance.send(:_guard_evaluate)
54
+ failures.none? { |f| f[:guard] == guard_name }
55
+ else
56
+ callable(**kwargs).ok?
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def _guard_own
63
+ @_guards ||= []
64
+ end
65
+
66
+ def _guard_normalize_requires!(code, requires)
67
+ return [] if requires.nil?
68
+
69
+ deps = Array(requires)
70
+ invalid = deps.reject { |d| d.is_a?(Symbol) }
71
+ if invalid.any?
72
+ raise ArgumentError,
73
+ "guard :#{code} requires: must be Symbol(s), got: #{invalid.map(&:inspect).join(", ")}"
74
+ end
75
+
76
+ all_names = _guard_list.map(&:name)
77
+ deps.each do |dep|
78
+ unless all_names.include?(dep)
79
+ raise ArgumentError,
80
+ "guard :#{code} requires :#{dep}, but no guard with that name has been declared"
81
+ end
82
+ end
83
+
84
+ deps.freeze
85
+ end
86
+
87
+ def _guard_validate_unique!(code)
88
+ all_names = _guard_list.map(&:name)
89
+ if all_names.include?(code)
90
+ raise ArgumentError, "duplicate guard name :#{code}"
91
+ end
92
+ end
93
+ end
94
+
95
+ def _guard_wrap
96
+ guards = self.class._guard_list
97
+ return yield if guards.empty?
98
+
99
+ failures = _guard_evaluate
100
+ unless failures.empty?
101
+ first = failures.first
102
+ error!(first[:guard], first[:message], details: failures)
103
+ end
104
+
105
+ yield
106
+ end
107
+
108
+ private
109
+
110
+ def _guard_evaluate_all
111
+ guards = self.class._guard_list
112
+ return [] if guards.empty?
113
+
114
+ blocked_names = Set.new
115
+ results = []
116
+
117
+ guards.each do |guard|
118
+ if guard.requires.any? { |dep| blocked_names.include?(dep) }
119
+ blocked_names << guard.name
120
+ results << { name: guard.name, passed: false, skipped: true }
121
+ next
122
+ end
123
+
124
+ threat = catch(:_dex_halt) { instance_exec(&guard.block) }
125
+ if threat.is_a?(Operation::Halt)
126
+ raise ArgumentError,
127
+ "guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
128
+ end
129
+
130
+ if threat
131
+ blocked_names << guard.name
132
+ results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
133
+ else
134
+ results << { name: guard.name, passed: true }
135
+ end
136
+ end
137
+
138
+ results
139
+ end
140
+
141
+ def _guard_evaluate
142
+ _guard_evaluate_all.filter_map do |r|
143
+ next if r[:passed] || r[:skipped]
144
+
145
+ { guard: r[:name], message: r[:message] }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -9,25 +9,33 @@ module Dex
9
9
  case name
10
10
  when :DirectJob
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
- def perform(class_name:, params:)
12
+ def perform(class_name:, params:, once_key: nil, once_bypass: false)
13
13
  klass = class_name.constantize
14
- klass.new(**klass.send(:_coerce_serialized_hash, params)).call
14
+ op = klass.new(**klass.send(:_coerce_serialized_hash, params))
15
+ op.once(once_key) if once_key
16
+ op.once(nil) if once_bypass
17
+ op.call
15
18
  end
16
19
  end)
17
20
  when :RecordJob
18
21
  const_set(:RecordJob, Class.new(ActiveJob::Base) do
19
- def perform(class_name:, record_id:)
22
+ def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
20
23
  klass = class_name.constantize
21
24
  record = Dex.record_backend.find_record(record_id)
22
25
  params = klass.send(:_coerce_serialized_hash, record.params || {})
23
26
 
24
27
  op = klass.new(**params)
25
28
  op.instance_variable_set(:@_dex_record_id, record_id)
29
+ op.once(once_key) if once_key
30
+ op.once(nil) if once_bypass
26
31
 
27
32
  update_status(record_id, status: "running")
33
+ pipeline_started = true
28
34
  op.call
29
35
  rescue => e
30
- handle_failure(record_id, e)
36
+ # RecordWrapper handles failures during op.call via its own rescue.
37
+ # This catches pre-pipeline failures (find_record, deserialization, etc.)
38
+ mark_failed(record_id, e) unless pipeline_started
31
39
  raise
32
40
  end
33
41
 
@@ -39,13 +47,12 @@ module Dex
39
47
  Dex.warn("Failed to update record status: #{e.message}")
40
48
  end
41
49
 
42
- def handle_failure(record_id, exception)
43
- error_value = if exception.is_a?(Dex::Error)
44
- exception.code.to_s
45
- else
46
- exception.class.name
47
- end
48
- update_status(record_id, status: "failed", error: error_value)
50
+ def mark_failed(record_id, exception)
51
+ update_status(record_id,
52
+ status: "failed",
53
+ error_code: exception.class.name,
54
+ error_message: exception.message,
55
+ performed_at: Time.respond_to?(:current) ? Time.current : Time.now)
49
56
  end
50
57
  end)
51
58
  when :Job