dexkit 0.6.0 → 0.7.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 +18 -0
- data/README.md +63 -0
- data/guides/llm/EVENT.md +31 -9
- data/guides/llm/OPERATION.md +246 -49
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event.rb +1 -0
- data/lib/dex/operation/async_proxy.rb +18 -2
- data/lib/dex/operation/guard_wrapper.rb +138 -0
- data/lib/dex/operation/jobs.rb +18 -11
- data/lib/dex/operation/once_wrapper.rb +240 -0
- data/lib/dex/operation/record_backend.rb +75 -0
- data/lib/dex/operation/record_wrapper.rb +87 -20
- data/lib/dex/operation.rb +18 -4
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +16 -0
- metadata +4 -1
|
@@ -0,0 +1,138 @@
|
|
|
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
|
|
111
|
+
guards = self.class._guard_list
|
|
112
|
+
return [] if guards.empty?
|
|
113
|
+
|
|
114
|
+
blocked_names = Set.new
|
|
115
|
+
failures = []
|
|
116
|
+
|
|
117
|
+
guards.each do |guard|
|
|
118
|
+
if guard.requires.any? { |dep| blocked_names.include?(dep) }
|
|
119
|
+
blocked_names << guard.name
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
threat = catch(:_dex_halt) { instance_exec(&guard.block) }
|
|
124
|
+
if threat.is_a?(Operation::Halt)
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if threat
|
|
130
|
+
blocked_names << guard.name
|
|
131
|
+
failures << { guard: guard.name, message: guard.message || guard.name.to_s }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
failures
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/dex/operation/jobs.rb
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
exception.
|
|
47
|
-
|
|
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
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module OnceWrapper
|
|
5
|
+
extend Dex::Concern
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def once(*props, expires_in: nil, &block)
|
|
9
|
+
if settings_for(:once)[:defined]
|
|
10
|
+
raise ArgumentError, "once can only be declared once per operation"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
record_settings = settings_for(:record)
|
|
14
|
+
if record_settings[:enabled] == false
|
|
15
|
+
raise ArgumentError, "once requires record to be enabled"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if record_settings[:result] == false
|
|
19
|
+
raise ArgumentError, "once requires result recording (cannot use record result: false)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if block && props.any?
|
|
23
|
+
raise ArgumentError, "once accepts either prop names or a block, not both"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if expires_in && !expires_in.is_a?(Numeric)
|
|
27
|
+
raise ArgumentError, "once :expires_in must be a duration, got: #{expires_in.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
_once_validate_props!(props) if props.any?
|
|
31
|
+
|
|
32
|
+
set(:once,
|
|
33
|
+
defined: true,
|
|
34
|
+
props: props.any? ? props : nil,
|
|
35
|
+
block: block || nil,
|
|
36
|
+
expires_in: expires_in)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear_once!(key = nil, **props)
|
|
40
|
+
derived = if key.is_a?(String)
|
|
41
|
+
key
|
|
42
|
+
elsif props.any?
|
|
43
|
+
_once_build_scoped_key(props)
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError, "pass a String key or keyword arguments matching the once props"
|
|
46
|
+
end
|
|
47
|
+
Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def _once_build_scoped_key(props_hash)
|
|
51
|
+
segments = props_hash.sort_by { |k, _| k.to_s }.map do |k, v|
|
|
52
|
+
"#{k}=#{URI.encode_www_form_component(v.to_s)}"
|
|
53
|
+
end
|
|
54
|
+
"#{name}/#{segments.join("/")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def _once_validate_props!(prop_names)
|
|
60
|
+
return unless respond_to?(:literal_properties)
|
|
61
|
+
|
|
62
|
+
defined_names = literal_properties.map(&:name).to_set
|
|
63
|
+
unknown = prop_names.reject { |p| defined_names.include?(p) }
|
|
64
|
+
return if unknown.empty?
|
|
65
|
+
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"once references unknown prop(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
68
|
+
"Defined: #{defined_names.map(&:inspect).join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def once(key)
|
|
73
|
+
@_once_key = key
|
|
74
|
+
@_once_key_explicit = true
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def _once_wrap
|
|
79
|
+
return yield unless _once_active?
|
|
80
|
+
|
|
81
|
+
key = _once_derive_key
|
|
82
|
+
return yield if key.nil? && _once_key_explicit?
|
|
83
|
+
|
|
84
|
+
_once_ensure_backend!
|
|
85
|
+
|
|
86
|
+
raise "once key must not be nil" if key.nil?
|
|
87
|
+
|
|
88
|
+
expires_in = self.class.settings_for(:once)[:expires_in]
|
|
89
|
+
expires_at = expires_in ? _once_current_time + expires_in : nil
|
|
90
|
+
|
|
91
|
+
existing = Dex.record_backend.find_by_once_key(key)
|
|
92
|
+
if existing
|
|
93
|
+
_once_finalize_duplicate!(existing)
|
|
94
|
+
return _once_replay!(existing)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
expired = Dex.record_backend.find_expired_once_key(key)
|
|
98
|
+
Dex.record_backend.update_record(expired.id.to_s, once_key: nil) if expired
|
|
99
|
+
|
|
100
|
+
_once_claim!(key, expires_at) { yield }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def _once_key_explicit?
|
|
106
|
+
defined?(@_once_key_explicit) && @_once_key_explicit
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def _once_active?
|
|
110
|
+
return true if _once_key_explicit?
|
|
111
|
+
self.class.settings_for(:once).fetch(:defined, false)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def _once_ensure_backend!
|
|
115
|
+
unless Dex.record_backend
|
|
116
|
+
raise "once requires a record backend (configure Dex.record_class)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return if self.class.instance_variable_defined?(:@_once_fields_checked)
|
|
120
|
+
|
|
121
|
+
unless Dex.record_backend.has_field?("once_key")
|
|
122
|
+
raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
|
|
126
|
+
raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
self.class.instance_variable_set(:@_once_fields_checked, true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def _once_derive_key
|
|
133
|
+
return @_once_key if _once_key_explicit?
|
|
134
|
+
|
|
135
|
+
settings = self.class.settings_for(:once)
|
|
136
|
+
|
|
137
|
+
if settings[:block]
|
|
138
|
+
instance_exec(&settings[:block])
|
|
139
|
+
elsif settings[:props]
|
|
140
|
+
props_hash = settings[:props].each_with_object({}) { |p, h| h[p] = public_send(p) }
|
|
141
|
+
self.class._once_build_scoped_key(props_hash)
|
|
142
|
+
else
|
|
143
|
+
hash = {}
|
|
144
|
+
if self.class.respond_to?(:literal_properties)
|
|
145
|
+
self.class.literal_properties.each { |p| hash[p.name] = public_send(p.name) }
|
|
146
|
+
end
|
|
147
|
+
self.class._once_build_scoped_key(hash)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def _once_claim!(key, expires_at)
|
|
152
|
+
begin
|
|
153
|
+
_once_acquire_key!(key, expires_at)
|
|
154
|
+
rescue => e
|
|
155
|
+
if Dex.record_backend.unique_constraint_error?(e)
|
|
156
|
+
existing = Dex.record_backend.find_by_once_key(key)
|
|
157
|
+
if existing
|
|
158
|
+
_once_finalize_duplicate!(existing)
|
|
159
|
+
return _once_replay!(existing)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
raise "once key #{key.inspect} is claimed by another in-flight execution"
|
|
163
|
+
end
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
yield
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def _once_acquire_key!(key, expires_at)
|
|
170
|
+
if _once_has_pending_record?
|
|
171
|
+
Dex.record_backend.update_record(@_dex_record_id,
|
|
172
|
+
once_key: key, once_key_expires_at: expires_at)
|
|
173
|
+
else
|
|
174
|
+
record = Dex.record_backend.create_record(
|
|
175
|
+
name: self.class.name,
|
|
176
|
+
once_key: key,
|
|
177
|
+
once_key_expires_at: expires_at,
|
|
178
|
+
status: "pending"
|
|
179
|
+
)
|
|
180
|
+
@_dex_record_id = record.id.to_s
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def _once_has_pending_record?
|
|
185
|
+
defined?(@_dex_record_id) && @_dex_record_id
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def _once_finalize_duplicate!(source_record)
|
|
189
|
+
return unless _once_has_pending_record?
|
|
190
|
+
|
|
191
|
+
attrs = { performed_at: _once_current_time }
|
|
192
|
+
if source_record.status == "error"
|
|
193
|
+
attrs[:status] = "error"
|
|
194
|
+
attrs[:error_code] = source_record.error_code
|
|
195
|
+
attrs[:error_message] = source_record.error_message
|
|
196
|
+
attrs[:error_details] = source_record.respond_to?(:error_details) ? source_record.error_details : nil
|
|
197
|
+
else
|
|
198
|
+
attrs[:status] = "completed"
|
|
199
|
+
attrs[:result] = source_record.respond_to?(:result) ? source_record.result : nil
|
|
200
|
+
end
|
|
201
|
+
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
202
|
+
rescue => e
|
|
203
|
+
Dex.warn("Failed to finalize replayed record: #{e.message}")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def _once_replay!(record)
|
|
207
|
+
case record.status
|
|
208
|
+
when "completed"
|
|
209
|
+
_once_replay_success(record)
|
|
210
|
+
when "error"
|
|
211
|
+
_once_replay_error(record)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def _once_replay_success(record)
|
|
216
|
+
stored = record.respond_to?(:result) ? record.result : nil
|
|
217
|
+
success_type = self.class.respond_to?(:_success_type) && self.class._success_type
|
|
218
|
+
|
|
219
|
+
if success_type && stored
|
|
220
|
+
self.class.send(:_coerce_value, success_type, stored)
|
|
221
|
+
elsif stored.is_a?(Hash) && stored.key?("_dex_value")
|
|
222
|
+
stored["_dex_value"]
|
|
223
|
+
else
|
|
224
|
+
stored
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def _once_replay_error(record)
|
|
229
|
+
raise Dex::Error.new(
|
|
230
|
+
record.error_code.to_sym,
|
|
231
|
+
record.error_message,
|
|
232
|
+
details: record.respond_to?(:error_details) ? record.error_details : nil
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def _once_current_time
|
|
237
|
+
Time.respond_to?(:current) ? Time.current : Time.now
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -34,6 +34,22 @@ module Dex
|
|
|
34
34
|
record_class.find(id).update!(safe_attributes(attributes))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def find_by_once_key(key)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_expired_once_key(key)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_record_by_once_key(key, **attributes)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unique_constraint_error?(exception)
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
37
53
|
def safe_attributes(attributes)
|
|
38
54
|
attributes.select { |key, _| has_field?(key.to_s) }
|
|
39
55
|
end
|
|
@@ -49,12 +65,71 @@ module Dex
|
|
|
49
65
|
@column_set = record_class.column_names.to_set
|
|
50
66
|
end
|
|
51
67
|
|
|
68
|
+
def find_by_once_key(key)
|
|
69
|
+
scope = record_class.where(once_key: key, status: %w[completed error])
|
|
70
|
+
scope = scope.where("once_key_expires_at IS NULL OR once_key_expires_at >= ?", Time.now) if has_field?("once_key_expires_at")
|
|
71
|
+
scope.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_expired_once_key(key)
|
|
75
|
+
return nil unless has_field?("once_key_expires_at")
|
|
76
|
+
|
|
77
|
+
record_class
|
|
78
|
+
.where(once_key: key, status: %w[completed error])
|
|
79
|
+
.where("once_key_expires_at IS NOT NULL AND once_key_expires_at < ?", Time.now)
|
|
80
|
+
.first
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_record_by_once_key(key, **attributes)
|
|
84
|
+
record = record_class.where(once_key: key, status: %w[completed error]).first
|
|
85
|
+
record&.update!(safe_attributes(attributes))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unique_constraint_error?(exception)
|
|
89
|
+
defined?(ActiveRecord::RecordNotUnique) && exception.is_a?(ActiveRecord::RecordNotUnique)
|
|
90
|
+
end
|
|
91
|
+
|
|
52
92
|
def has_field?(field_name)
|
|
53
93
|
@column_set.include?(field_name.to_s)
|
|
54
94
|
end
|
|
55
95
|
end
|
|
56
96
|
|
|
57
97
|
class MongoidAdapter < Base
|
|
98
|
+
def find_by_once_key(key)
|
|
99
|
+
now = Time.now
|
|
100
|
+
record_class.where(
|
|
101
|
+
:once_key => key,
|
|
102
|
+
:status.in => %w[completed error]
|
|
103
|
+
).and(
|
|
104
|
+
record_class.or(
|
|
105
|
+
{ once_key_expires_at: nil },
|
|
106
|
+
{ :once_key_expires_at.gte => now }
|
|
107
|
+
)
|
|
108
|
+
).first
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def find_expired_once_key(key)
|
|
112
|
+
return nil unless has_field?("once_key_expires_at")
|
|
113
|
+
|
|
114
|
+
record_class.where(
|
|
115
|
+
:once_key => key,
|
|
116
|
+
:status.in => %w[completed error],
|
|
117
|
+
:once_key_expires_at.ne => nil,
|
|
118
|
+
:once_key_expires_at.lt => Time.now
|
|
119
|
+
).first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_record_by_once_key(key, **attributes)
|
|
123
|
+
record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
|
|
124
|
+
record&.update!(safe_attributes(attributes))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def unique_constraint_error?(exception)
|
|
128
|
+
defined?(Mongo::Error::OperationFailure) &&
|
|
129
|
+
exception.is_a?(Mongo::Error::OperationFailure) &&
|
|
130
|
+
exception.code == 11_000
|
|
131
|
+
end
|
|
132
|
+
|
|
58
133
|
def has_field?(field_name)
|
|
59
134
|
record_class.fields.key?(field_name.to_s)
|
|
60
135
|
end
|
|
@@ -7,26 +7,27 @@ module Dex
|
|
|
7
7
|
def _record_wrap
|
|
8
8
|
interceptor = Operation::HaltInterceptor.new { yield }
|
|
9
9
|
|
|
10
|
-
if
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
_record_save!(interceptor.result)
|
|
15
|
-
end
|
|
10
|
+
if _record_has_pending_record?
|
|
11
|
+
_record_update_outcome!(interceptor)
|
|
12
|
+
elsif _record_enabled?
|
|
13
|
+
_record_save!(interceptor)
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
interceptor.rethrow!
|
|
19
17
|
interceptor.result
|
|
18
|
+
rescue => e
|
|
19
|
+
_record_failure!(e) if _record_has_pending_record? || _record_enabled?
|
|
20
|
+
raise
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
module ClassMethods
|
|
23
24
|
def record(enabled = nil, **options)
|
|
24
|
-
validate_options!(options, %i[params
|
|
25
|
+
validate_options!(options, %i[params result], :record)
|
|
25
26
|
|
|
26
27
|
if enabled == false
|
|
27
28
|
set :record, enabled: false
|
|
28
29
|
elsif enabled == true || enabled.nil?
|
|
29
|
-
merged = { enabled: true, params: true,
|
|
30
|
+
merged = { enabled: true, params: true, result: true }.merge(options)
|
|
30
31
|
set :record, **merged
|
|
31
32
|
else
|
|
32
33
|
raise ArgumentError,
|
|
@@ -49,27 +50,78 @@ module Dex
|
|
|
49
50
|
defined?(@_dex_record_id) && @_dex_record_id
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def _record_save!(
|
|
53
|
-
|
|
53
|
+
def _record_save!(interceptor)
|
|
54
|
+
attrs = _record_base_attrs
|
|
55
|
+
if interceptor.error?
|
|
56
|
+
attrs.merge!(_record_error_attrs(code: interceptor.halt.error_code,
|
|
57
|
+
message: interceptor.halt.error_message,
|
|
58
|
+
details: interceptor.halt.error_details))
|
|
59
|
+
else
|
|
60
|
+
attrs.merge!(_record_success_attrs(interceptor.result))
|
|
61
|
+
end
|
|
62
|
+
Dex.record_backend.create_record(attrs)
|
|
54
63
|
rescue => e
|
|
55
64
|
_record_handle_error(e)
|
|
56
65
|
end
|
|
57
66
|
|
|
58
|
-
def
|
|
59
|
-
attrs =
|
|
60
|
-
|
|
67
|
+
def _record_update_outcome!(interceptor)
|
|
68
|
+
attrs = if interceptor.error?
|
|
69
|
+
_record_error_attrs(code: interceptor.halt.error_code,
|
|
70
|
+
message: interceptor.halt.error_message,
|
|
71
|
+
details: interceptor.halt.error_details)
|
|
72
|
+
else
|
|
73
|
+
_record_success_attrs(interceptor.result)
|
|
74
|
+
end
|
|
61
75
|
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
62
76
|
rescue => e
|
|
63
77
|
_record_handle_error(e)
|
|
64
78
|
end
|
|
65
79
|
|
|
66
|
-
def
|
|
67
|
-
attrs =
|
|
80
|
+
def _record_failure!(exception)
|
|
81
|
+
attrs = if exception.is_a?(Dex::Error)
|
|
82
|
+
_record_error_attrs(code: exception.code, message: exception.message, details: exception.details)
|
|
83
|
+
else
|
|
84
|
+
{
|
|
85
|
+
status: "failed",
|
|
86
|
+
error_code: exception.class.name,
|
|
87
|
+
error_message: exception.message,
|
|
88
|
+
performed_at: _record_current_time
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
|
|
93
|
+
|
|
94
|
+
if _record_has_pending_record?
|
|
95
|
+
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
96
|
+
else
|
|
97
|
+
Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
|
|
98
|
+
end
|
|
99
|
+
rescue => e
|
|
100
|
+
_record_handle_error(e)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def _record_base_attrs
|
|
104
|
+
attrs = { name: self.class.name }
|
|
68
105
|
attrs[:params] = _record_params? ? _record_params : nil
|
|
69
|
-
attrs[:response] = _record_response? ? _record_response(result) : nil
|
|
70
106
|
attrs
|
|
71
107
|
end
|
|
72
108
|
|
|
109
|
+
def _record_success_attrs(result)
|
|
110
|
+
attrs = { status: "completed", performed_at: _record_current_time }
|
|
111
|
+
attrs[:result] = _record_result(result) if _record_result?
|
|
112
|
+
attrs
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def _record_error_attrs(code:, message:, details:)
|
|
116
|
+
{
|
|
117
|
+
status: "error",
|
|
118
|
+
error_code: code.to_s,
|
|
119
|
+
error_message: message || code.to_s,
|
|
120
|
+
error_details: _record_sanitize_details(details),
|
|
121
|
+
performed_at: _record_current_time
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
73
125
|
def _record_params
|
|
74
126
|
_props_as_json
|
|
75
127
|
end
|
|
@@ -78,11 +130,11 @@ module Dex
|
|
|
78
130
|
self.class.settings_for(:record).fetch(:params, true)
|
|
79
131
|
end
|
|
80
132
|
|
|
81
|
-
def
|
|
82
|
-
self.class.settings_for(:record).fetch(:
|
|
133
|
+
def _record_result?
|
|
134
|
+
self.class.settings_for(:record).fetch(:result, true)
|
|
83
135
|
end
|
|
84
136
|
|
|
85
|
-
def
|
|
137
|
+
def _record_result(result)
|
|
86
138
|
success_type = self.class.respond_to?(:_success_type) && self.class._success_type
|
|
87
139
|
|
|
88
140
|
if success_type
|
|
@@ -91,7 +143,7 @@ module Dex
|
|
|
91
143
|
case result
|
|
92
144
|
when nil then nil
|
|
93
145
|
when Hash then result
|
|
94
|
-
else {
|
|
146
|
+
else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
|
|
95
147
|
end
|
|
96
148
|
end
|
|
97
149
|
end
|
|
@@ -100,6 +152,21 @@ module Dex
|
|
|
100
152
|
Time.respond_to?(:current) ? Time.current : Time.now
|
|
101
153
|
end
|
|
102
154
|
|
|
155
|
+
def _record_sanitize_details(details)
|
|
156
|
+
_record_sanitize_value(details)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def _record_sanitize_value(value)
|
|
160
|
+
case value
|
|
161
|
+
when NilClass, String, Integer, Float, TrueClass, FalseClass then value
|
|
162
|
+
when Symbol then value.to_s
|
|
163
|
+
when Hash then value.transform_values { |v| _record_sanitize_value(v) }
|
|
164
|
+
when Array then value.map { |v| _record_sanitize_value(v) }
|
|
165
|
+
when Exception then "#{value.class}: #{value.message}"
|
|
166
|
+
else value.to_s
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
103
170
|
def _record_handle_error(error)
|
|
104
171
|
Dex.warn("Failed to record operation: #{error.message}")
|
|
105
172
|
end
|