dexkit 0.10.0 → 0.11.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 +36 -2
- data/README.md +62 -281
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +1 -7
- data/guides/llm/OPERATION.md +88 -54
- data/guides/llm/QUERY.md +6 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/event/bus.rb +1 -3
- data/lib/dex/event/handler.rb +1 -2
- data/lib/dex/event/metadata.rb +3 -15
- data/lib/dex/event/processor.rb +1 -15
- data/lib/dex/event.rb +1 -3
- data/lib/dex/id.rb +92 -5
- data/lib/dex/operation/async_proxy.rb +10 -2
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -112
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation.rb +1 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +19 -3
- metadata +5 -3
- data/lib/dex/event/trace.rb +0 -43
- data/lib/dex/event_test_helpers.rb +0 -3
data/lib/dex/id.rb
CHANGED
|
@@ -5,13 +5,53 @@ require "securerandom"
|
|
|
5
5
|
module Dex
|
|
6
6
|
module Id
|
|
7
7
|
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
8
|
+
BASE = ALPHABET.length
|
|
8
9
|
TIMESTAMP_WIDTH = 8
|
|
9
|
-
|
|
10
|
+
DEFAULT_RANDOM_WIDTH = 12
|
|
11
|
+
MIN_RANDOM_WIDTH = 8
|
|
12
|
+
PREFIX_PATTERN = /\A[a-z][a-z0-9_]*_\z/
|
|
13
|
+
MIN_PAYLOAD_LENGTH = 9 # 8 timestamp + at least 1 random
|
|
14
|
+
|
|
15
|
+
ALPHABET_INDEX = ALPHABET.each_char.with_index.to_h.freeze
|
|
16
|
+
|
|
17
|
+
Parsed = Data.define(:prefix, :created_at, :random)
|
|
10
18
|
|
|
11
19
|
module_function
|
|
12
20
|
|
|
13
|
-
def generate(prefix)
|
|
14
|
-
|
|
21
|
+
def generate(prefix, random: DEFAULT_RANDOM_WIDTH)
|
|
22
|
+
validate_prefix!(prefix)
|
|
23
|
+
validate_random_width!(random)
|
|
24
|
+
|
|
25
|
+
"#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(random)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(id)
|
|
29
|
+
id = String(id)
|
|
30
|
+
last_underscore = id.rindex("_")
|
|
31
|
+
|
|
32
|
+
unless last_underscore
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"Cannot parse #{id.inspect}: no underscore found. Dex::Id strings have the format \"prefix_<timestamp><random>\"."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
prefix = id[0..last_underscore]
|
|
38
|
+
payload = id[(last_underscore + 1)..]
|
|
39
|
+
|
|
40
|
+
if payload.length < MIN_PAYLOAD_LENGTH
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"Cannot parse #{id.inspect}: payload after prefix is #{payload.length} characters, " \
|
|
43
|
+
"need at least #{MIN_PAYLOAD_LENGTH} (#{TIMESTAMP_WIDTH} timestamp + 1 random)."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
validate_base58!(payload, id)
|
|
47
|
+
|
|
48
|
+
timestamp_chars = payload[0, TIMESTAMP_WIDTH]
|
|
49
|
+
random_chars = payload[TIMESTAMP_WIDTH..]
|
|
50
|
+
|
|
51
|
+
ms = base58_decode(timestamp_chars)
|
|
52
|
+
created_at = Time.at(ms / 1000, ms % 1000 * 1000, :usec).utc
|
|
53
|
+
|
|
54
|
+
Parsed.new(prefix: prefix, created_at: created_at, random: random_chars)
|
|
15
55
|
end
|
|
16
56
|
|
|
17
57
|
def base58_encode(number, width = nil)
|
|
@@ -19,7 +59,7 @@ module Dex
|
|
|
19
59
|
value = number.to_i
|
|
20
60
|
|
|
21
61
|
loop do
|
|
22
|
-
value, remainder = value.divmod(
|
|
62
|
+
value, remainder = value.divmod(BASE)
|
|
23
63
|
encoded.prepend(ALPHABET[remainder])
|
|
24
64
|
break unless value.positive?
|
|
25
65
|
end
|
|
@@ -27,12 +67,59 @@ module Dex
|
|
|
27
67
|
width ? encoded.rjust(width, ALPHABET[0]) : encoded
|
|
28
68
|
end
|
|
29
69
|
|
|
70
|
+
def base58_decode(string)
|
|
71
|
+
raise ArgumentError, "expected a String, got #{string.inspect}" unless string.is_a?(String)
|
|
72
|
+
|
|
73
|
+
value = 0
|
|
74
|
+
string.each_char do |char|
|
|
75
|
+
index = ALPHABET_INDEX[char]
|
|
76
|
+
raise ArgumentError, "invalid base58 character #{char.inspect} in #{string.inspect}" unless index
|
|
77
|
+
|
|
78
|
+
value = value * BASE + index
|
|
79
|
+
end
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
|
|
30
83
|
def random_suffix(width)
|
|
31
|
-
Array.new(width) { ALPHABET[SecureRandom.random_number(
|
|
84
|
+
Array.new(width) { ALPHABET[SecureRandom.random_number(BASE)] }.join
|
|
32
85
|
end
|
|
33
86
|
|
|
34
87
|
def current_milliseconds
|
|
35
88
|
Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
36
89
|
end
|
|
90
|
+
|
|
91
|
+
def validate_prefix!(prefix)
|
|
92
|
+
raise ArgumentError, "prefix must be a non-empty String" unless prefix.is_a?(String) && !prefix.empty?
|
|
93
|
+
|
|
94
|
+
return if PREFIX_PATTERN.match?(prefix)
|
|
95
|
+
|
|
96
|
+
unless prefix.end_with?("_")
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"Invalid prefix #{prefix.inspect}: prefix must end with underscore. Did you mean #{"#{prefix}_".inspect}?"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"Invalid prefix #{prefix.inspect}: prefix must match #{PREFIX_PATTERN.inspect} " \
|
|
103
|
+
"(lowercase alphanumeric with internal underscores, ending in underscore)."
|
|
104
|
+
end
|
|
105
|
+
private_class_method :validate_prefix!
|
|
106
|
+
|
|
107
|
+
def validate_random_width!(width)
|
|
108
|
+
unless width.is_a?(Integer) && width >= MIN_RANDOM_WIDTH
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"random: must be an Integer >= #{MIN_RANDOM_WIDTH}, got #{width.inspect}."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
private_class_method :validate_random_width!
|
|
114
|
+
|
|
115
|
+
def validate_base58!(payload, original_id)
|
|
116
|
+
payload.each_char do |char|
|
|
117
|
+
next if ALPHABET_INDEX.key?(char)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Cannot parse #{original_id.inspect}: invalid base58 character #{char.inspect} in payload."
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
private_class_method :validate_base58!
|
|
37
124
|
end
|
|
38
125
|
end
|
|
@@ -17,6 +17,12 @@ module Dex
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def safe(*)
|
|
21
|
+
raise NoMethodError,
|
|
22
|
+
"safe and async are alternative execution strategies. " \
|
|
23
|
+
"For async outcome reconstruction, use wait/wait! on the ticket."
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
private
|
|
21
27
|
|
|
22
28
|
def enqueue_direct_job
|
|
@@ -27,7 +33,8 @@ module Dex
|
|
|
27
33
|
trace: Dex::Trace.dump
|
|
28
34
|
}
|
|
29
35
|
apply_once_payload!(payload)
|
|
30
|
-
job.perform_later(**payload)
|
|
36
|
+
job = job.perform_later(**payload)
|
|
37
|
+
Operation::Ticket.new(record: nil, job: job)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def enqueue_record_job
|
|
@@ -47,7 +54,8 @@ module Dex
|
|
|
47
54
|
trace: Dex::Trace.dump
|
|
48
55
|
}
|
|
49
56
|
apply_once_payload!(payload)
|
|
50
|
-
job.perform_later(**payload)
|
|
57
|
+
job = job.perform_later(**payload)
|
|
58
|
+
Operation::Ticket.new(record: record, job: job)
|
|
51
59
|
rescue => e
|
|
52
60
|
begin
|
|
53
61
|
record.destroy
|
|
@@ -124,7 +124,7 @@ module Dex
|
|
|
124
124
|
threat = catch(:_dex_halt) { instance_exec(&guard.block) }
|
|
125
125
|
if threat.is_a?(Operation::Halt)
|
|
126
126
|
raise ArgumentError,
|
|
127
|
-
"guard :#{guard.name} must return truthy/falsy, not call error!/success
|
|
127
|
+
"guard :#{guard.name} must return truthy/falsy, not call error!/success!"
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
if threat
|
|
@@ -26,6 +26,10 @@ module Dex
|
|
|
26
26
|
@value.respond_to?(method, include_private) || super
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def deconstruct
|
|
30
|
+
[@value]
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
def deconstruct_keys(keys)
|
|
30
34
|
return { value: @value } unless @value.respond_to?(:deconstruct_keys)
|
|
31
35
|
@value.deconstruct_keys(keys)
|
|
@@ -49,6 +53,10 @@ module Dex
|
|
|
49
53
|
def message = @error.message
|
|
50
54
|
def details = @error.details
|
|
51
55
|
|
|
56
|
+
def deconstruct
|
|
57
|
+
[@error]
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
def deconstruct_keys(keys)
|
|
53
61
|
{ code: @error.code, message: @error.message, details: @error.details }
|
|
54
62
|
end
|
|
@@ -65,6 +73,12 @@ module Dex
|
|
|
65
73
|
rescue Dex::Error => e
|
|
66
74
|
Operation::Err.new(e)
|
|
67
75
|
end
|
|
76
|
+
|
|
77
|
+
def async(*)
|
|
78
|
+
raise NoMethodError,
|
|
79
|
+
"safe and async are alternative execution strategies. " \
|
|
80
|
+
"For async outcome reconstruction, use wait/wait! on the ticket."
|
|
81
|
+
end
|
|
68
82
|
end
|
|
69
83
|
end
|
|
70
84
|
end
|
|
@@ -57,18 +57,6 @@ module Dex
|
|
|
57
57
|
throw(:_dex_halt, Operation::Halt.new(type: :success, value: attrs.empty? ? value : attrs))
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def assert!(*args, &block)
|
|
61
|
-
if block
|
|
62
|
-
code = args[0]
|
|
63
|
-
value = yield
|
|
64
|
-
else
|
|
65
|
-
value, code = args
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
error!(code) unless value
|
|
69
|
-
value
|
|
70
|
-
end
|
|
71
|
-
|
|
72
60
|
private
|
|
73
61
|
|
|
74
62
|
def _result_validate_success_type!(value)
|
|
@@ -52,39 +52,6 @@ module Dex
|
|
|
52
52
|
result
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# --- One-liner assertions ---
|
|
56
|
-
|
|
57
|
-
def assert_operation(*args, returns: :_not_given, **params)
|
|
58
|
-
klass = _dex_resolve_subject(args)
|
|
59
|
-
result = klass.new(**params).safe.call
|
|
60
|
-
assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
|
|
61
|
-
if returns != :_not_given
|
|
62
|
-
assert_equal returns, result.value, "Return value mismatch"
|
|
63
|
-
end
|
|
64
|
-
result
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def assert_operation_error(*args, message: nil, details: nil, **params)
|
|
68
|
-
klass, code = _dex_resolve_subject_and_code(args)
|
|
69
|
-
result = klass.new(**params).safe.call
|
|
70
|
-
assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
|
|
71
|
-
if code
|
|
72
|
-
assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
73
|
-
end
|
|
74
|
-
if message
|
|
75
|
-
case message
|
|
76
|
-
when Regexp
|
|
77
|
-
assert_match message, result.message
|
|
78
|
-
else
|
|
79
|
-
assert_equal message, result.message
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
details&.each do |key, val|
|
|
83
|
-
assert_equal val, result.details&.dig(key)
|
|
84
|
-
end
|
|
85
|
-
result
|
|
86
|
-
end
|
|
87
|
-
|
|
88
55
|
# --- Contract assertions ---
|
|
89
56
|
|
|
90
57
|
def assert_params(*args)
|
|
@@ -199,30 +166,6 @@ module Dex
|
|
|
199
166
|
"Expected no operations to be enqueued, but #{after_count - before_count} were"
|
|
200
167
|
end
|
|
201
168
|
|
|
202
|
-
# --- Trace assertions ---
|
|
203
|
-
|
|
204
|
-
def assert_trace_includes(operation_class, msg: nil)
|
|
205
|
-
expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
|
|
206
|
-
trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
|
|
207
|
-
|
|
208
|
-
assert_includes trace_classes, expected,
|
|
209
|
-
msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def assert_trace_actor(type:, id: :_not_given, msg: nil)
|
|
213
|
-
actor = Dex::Trace.actor
|
|
214
|
-
refute_nil actor, msg || "Expected a trace actor, but no actor is set"
|
|
215
|
-
assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
|
|
216
|
-
assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
|
|
217
|
-
actor
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def assert_trace_depth(expected, msg: nil)
|
|
221
|
-
actual = Dex::Trace.current.size
|
|
222
|
-
assert_equal expected, actual,
|
|
223
|
-
msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
|
|
224
|
-
end
|
|
225
|
-
|
|
226
169
|
# --- Transaction assertions ---
|
|
227
170
|
|
|
228
171
|
def assert_rolls_back(model_class, &block)
|
|
@@ -262,49 +205,6 @@ module Dex
|
|
|
262
205
|
result
|
|
263
206
|
end
|
|
264
207
|
|
|
265
|
-
# --- Batch assertions ---
|
|
266
|
-
|
|
267
|
-
def assert_all_succeed(*args, params_list:)
|
|
268
|
-
klass = _dex_resolve_subject(args)
|
|
269
|
-
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
270
|
-
failures = results.each_with_index.reject { |r, _| r.ok? }
|
|
271
|
-
if failures.any?
|
|
272
|
-
msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
|
|
273
|
-
flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
|
|
274
|
-
end
|
|
275
|
-
results
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
|
|
279
|
-
klass = _dex_resolve_subject(args)
|
|
280
|
-
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
281
|
-
failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
|
|
282
|
-
if failures.any?
|
|
283
|
-
msgs = failures.map { |r, i|
|
|
284
|
-
status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
|
|
285
|
-
" [#{i}] #{params_list[i].inspect} => #{status}"
|
|
286
|
-
}
|
|
287
|
-
flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
|
|
288
|
-
end
|
|
289
|
-
results.each_with_index do |r, i|
|
|
290
|
-
if message
|
|
291
|
-
case message
|
|
292
|
-
when Regexp
|
|
293
|
-
assert_match message, r.message,
|
|
294
|
-
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
295
|
-
else
|
|
296
|
-
assert_equal message, r.message,
|
|
297
|
-
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
details&.each do |key, val|
|
|
301
|
-
assert_equal val, r.details&.dig(key),
|
|
302
|
-
"Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
results
|
|
306
|
-
end
|
|
307
|
-
|
|
308
208
|
private
|
|
309
209
|
|
|
310
210
|
def _dex_format_err(result)
|
|
@@ -322,18 +222,6 @@ module Dex
|
|
|
322
222
|
" value: #{result.value.inspect}"
|
|
323
223
|
end
|
|
324
224
|
|
|
325
|
-
def _dex_resolve_subject_and_code(args)
|
|
326
|
-
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
327
|
-
klass = args.shift
|
|
328
|
-
code = args.shift
|
|
329
|
-
[klass, code]
|
|
330
|
-
elsif args.first.is_a?(Symbol)
|
|
331
|
-
[_dex_resolve_subject([]), args.shift]
|
|
332
|
-
else
|
|
333
|
-
[_dex_resolve_subject([]), nil]
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
|
|
337
225
|
def _dex_split_class_and_symbols(args)
|
|
338
226
|
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
339
227
|
[args[0..0], args[1..]]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
class Ticket
|
|
6
|
+
WAIT_MAX_RECOMMENDED = 10
|
|
7
|
+
|
|
8
|
+
attr_reader :record, :job
|
|
9
|
+
|
|
10
|
+
def initialize(record:, job:)
|
|
11
|
+
@record = record
|
|
12
|
+
@job = job
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_record(record)
|
|
16
|
+
raise ArgumentError, "from_record requires a record, got nil" unless record
|
|
17
|
+
|
|
18
|
+
new(record: record, job: nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- Delegated accessors ---
|
|
22
|
+
|
|
23
|
+
def id
|
|
24
|
+
_require_record!("id")
|
|
25
|
+
record.id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def operation_name
|
|
29
|
+
_require_record!("operation_name")
|
|
30
|
+
record.name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def status
|
|
34
|
+
_require_record!("status")
|
|
35
|
+
record.status
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error_code
|
|
39
|
+
_require_record!("error_code")
|
|
40
|
+
record.error_code
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error_message
|
|
44
|
+
_require_record!("error_message")
|
|
45
|
+
record.error_message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_details
|
|
49
|
+
_require_record!("error_details")
|
|
50
|
+
record.error_details
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- Predicates ---
|
|
54
|
+
|
|
55
|
+
def completed?
|
|
56
|
+
status == "completed"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def error?
|
|
60
|
+
status == "error"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def failed?
|
|
64
|
+
status == "failed"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pending?
|
|
68
|
+
status == "pending"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def running?
|
|
72
|
+
status == "running"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def terminal?
|
|
76
|
+
completed? || error? || failed?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def recorded?
|
|
80
|
+
!record.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- Reload ---
|
|
84
|
+
|
|
85
|
+
def reload
|
|
86
|
+
_require_record!("reload")
|
|
87
|
+
@record = Dex.record_backend.find_record(record.id)
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- Outcome reconstruction ---
|
|
92
|
+
|
|
93
|
+
def outcome
|
|
94
|
+
_require_record!("outcome")
|
|
95
|
+
|
|
96
|
+
case status
|
|
97
|
+
when "completed"
|
|
98
|
+
value = _unwrap_result(record.result)
|
|
99
|
+
value = _coerce_typed_result(value)
|
|
100
|
+
Ok.new(_symbolize_keys(value))
|
|
101
|
+
when "error"
|
|
102
|
+
code = record.error_code&.to_sym
|
|
103
|
+
message = record.error_message
|
|
104
|
+
details = record.error_details
|
|
105
|
+
details = _symbolize_keys(details) if details.is_a?(Hash)
|
|
106
|
+
Err.new(Dex::Error.new(code, message, details: details))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- Wait ---
|
|
111
|
+
|
|
112
|
+
def wait(timeout, interval: 0.2)
|
|
113
|
+
_validate_wait_args!(timeout, interval)
|
|
114
|
+
|
|
115
|
+
if timeout.to_f > WAIT_MAX_RECOMMENDED
|
|
116
|
+
Dex.warn(
|
|
117
|
+
"Ticket#wait called with #{timeout}s timeout. " \
|
|
118
|
+
"Speculative sync is designed for short waits (under #{WAIT_MAX_RECOMMENDED}s). " \
|
|
119
|
+
"Consider client-side polling for long-running operations."
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
interval_fn = interval.respond_to?(:call) ? interval : ->(_) { interval }
|
|
124
|
+
deadline = _monotonic_now + timeout.to_f
|
|
125
|
+
attempt = 0
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
if terminal?
|
|
129
|
+
result = outcome
|
|
130
|
+
raise _build_operation_failed unless result
|
|
131
|
+
return result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
remaining = deadline - _monotonic_now
|
|
135
|
+
return nil if remaining <= 0
|
|
136
|
+
|
|
137
|
+
pause = [interval_fn.call(attempt).to_f, 0.01].max
|
|
138
|
+
sleep [pause, remaining].min
|
|
139
|
+
attempt += 1
|
|
140
|
+
reload
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def wait!(timeout, **opts)
|
|
145
|
+
result = wait(timeout, **opts)
|
|
146
|
+
raise Dex::Timeout.new(timeout: timeout, ticket_id: id, operation_name: operation_name) unless result
|
|
147
|
+
return result.value if result.ok?
|
|
148
|
+
|
|
149
|
+
raise result.error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- to_param ---
|
|
153
|
+
|
|
154
|
+
def to_param
|
|
155
|
+
id.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- as_json ---
|
|
159
|
+
|
|
160
|
+
def as_json(*)
|
|
161
|
+
_require_record!("as_json")
|
|
162
|
+
|
|
163
|
+
data = { "id" => id.to_s, "name" => operation_name, "status" => status }
|
|
164
|
+
|
|
165
|
+
case status
|
|
166
|
+
when "completed"
|
|
167
|
+
result = _unwrap_result(record.result)
|
|
168
|
+
data["result"] = result unless result.nil?
|
|
169
|
+
when "error"
|
|
170
|
+
data["error"] = {
|
|
171
|
+
"code" => record.error_code,
|
|
172
|
+
"message" => record.error_message,
|
|
173
|
+
"details" => record.error_details
|
|
174
|
+
}.compact
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
data
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# --- inspect ---
|
|
181
|
+
|
|
182
|
+
def inspect
|
|
183
|
+
if record
|
|
184
|
+
"#<Dex::Operation::Ticket #{operation_name} id=#{id.inspect} status=#{status.inspect}>"
|
|
185
|
+
else
|
|
186
|
+
"#<Dex::Operation::Ticket (unrecorded) job=#{job&.class&.name}>"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def _require_record!(method)
|
|
193
|
+
return if record
|
|
194
|
+
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"#{method} requires a recorded operation. " \
|
|
197
|
+
"Enable recording with `record true` in your operation, or use `Ticket.from_record` with an existing record."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def _validate_wait_args!(timeout, interval)
|
|
201
|
+
unless record
|
|
202
|
+
raise ArgumentError,
|
|
203
|
+
"wait requires a recorded operation. Possible causes: " \
|
|
204
|
+
"(1) recording is not enabled — add `record true` to your operation; " \
|
|
205
|
+
"(2) `record params: false` is set — async operations store params in the record to reconstruct " \
|
|
206
|
+
"the operation in the background job, so params: false forces the direct (non-recorded) strategy. " \
|
|
207
|
+
"If you need to avoid storing params (e.g., PII), consider encrypting params at the model level instead."
|
|
208
|
+
end
|
|
209
|
+
unless _valid_duration?(timeout) && timeout.to_f > 0
|
|
210
|
+
raise ArgumentError, "timeout must be a positive Numeric, got: #{timeout.inspect}"
|
|
211
|
+
end
|
|
212
|
+
if !interval.respond_to?(:call) && !(_valid_duration?(interval) && interval.to_f > 0)
|
|
213
|
+
raise ArgumentError, "interval must be a positive number or a callable, got: #{interval.inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def _build_operation_failed
|
|
218
|
+
Dex::OperationFailed.new(
|
|
219
|
+
operation_name: record.name || "Unknown",
|
|
220
|
+
exception_class: record.error_code || "Unknown",
|
|
221
|
+
exception_message: record.error_message || "(no message recorded)"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def _valid_duration?(value)
|
|
226
|
+
return true if value.is_a?(Numeric)
|
|
227
|
+
return true if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
|
|
228
|
+
|
|
229
|
+
false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def _coerce_typed_result(value)
|
|
233
|
+
return value if value.nil?
|
|
234
|
+
|
|
235
|
+
klass = record.name&.safe_constantize
|
|
236
|
+
return value unless klass
|
|
237
|
+
|
|
238
|
+
success_type = klass.respond_to?(:_success_type) && klass._success_type
|
|
239
|
+
return value unless success_type
|
|
240
|
+
|
|
241
|
+
klass.send(:_coerce_value, success_type, value)
|
|
242
|
+
rescue
|
|
243
|
+
value
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def _unwrap_result(result)
|
|
247
|
+
return result unless result.is_a?(Hash) && result.key?("_dex_value")
|
|
248
|
+
|
|
249
|
+
result["_dex_value"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def _symbolize_keys(value)
|
|
253
|
+
case value
|
|
254
|
+
when Hash
|
|
255
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = _symbolize_keys(v) }
|
|
256
|
+
when Array
|
|
257
|
+
value.map { |v| _symbolize_keys(v) }
|
|
258
|
+
else
|
|
259
|
+
value
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def _monotonic_now
|
|
264
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
data/lib/dex/operation.rb
CHANGED
|
@@ -158,6 +158,7 @@ require_relative "operation/async_proxy"
|
|
|
158
158
|
require_relative "operation/record_backend"
|
|
159
159
|
require_relative "operation/transaction_adapter"
|
|
160
160
|
require_relative "operation/jobs"
|
|
161
|
+
require_relative "operation/ticket"
|
|
161
162
|
require_relative "operation/explain"
|
|
162
163
|
require_relative "operation/export"
|
|
163
164
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class OperationFailed < StandardError
|
|
5
|
+
attr_reader :operation_name, :exception_class, :exception_message
|
|
6
|
+
|
|
7
|
+
def initialize(operation_name:, exception_class:, exception_message:)
|
|
8
|
+
@operation_name = operation_name
|
|
9
|
+
@exception_class = exception_class
|
|
10
|
+
@exception_message = exception_message
|
|
11
|
+
super("#{operation_name} failed with #{exception_class}: #{exception_message}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/dex/timeout.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Timeout < StandardError
|
|
5
|
+
attr_reader :timeout, :ticket_id, :operation_name
|
|
6
|
+
|
|
7
|
+
def initialize(timeout:, ticket_id:, operation_name:)
|
|
8
|
+
@timeout = timeout.to_f
|
|
9
|
+
@ticket_id = ticket_id
|
|
10
|
+
@operation_name = operation_name
|
|
11
|
+
super("#{operation_name} did not complete within #{@timeout}s (ticket: #{ticket_id})")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|