dexkit 0.5.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 +31 -0
- data/README.md +69 -2
- data/guides/llm/EVENT.md +95 -9
- data/guides/llm/OPERATION.md +246 -49
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event/handler.rb +20 -1
- data/lib/dex/event.rb +1 -2
- data/lib/dex/executable.rb +44 -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/transaction_wrapper.rb +2 -1
- data/lib/dex/operation.rb +16 -36
- data/lib/dex/pipeline.rb +58 -0
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +19 -0
- metadata +11 -6
- data/lib/dex/operation/pipeline.rb +0 -60
- /data/lib/dex/{operation/settings.rb → settings.rb} +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
# Shared context DSL for Operation and Event.
|
|
5
|
+
#
|
|
6
|
+
# Maps declared props to ambient context keys so they can be auto-filled
|
|
7
|
+
# from Dex.context when not passed explicitly as kwargs.
|
|
8
|
+
module ContextSetup
|
|
9
|
+
extend Dex::Concern
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def context(*names, **mappings)
|
|
13
|
+
names.each do |name|
|
|
14
|
+
unless name.is_a?(Symbol)
|
|
15
|
+
raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
|
|
16
|
+
end
|
|
17
|
+
mappings[name] = name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
raise ArgumentError, "context requires at least one mapping" if mappings.empty?
|
|
21
|
+
|
|
22
|
+
mappings.each do |prop_name, context_key|
|
|
23
|
+
unless _context_prop_declared?(prop_name)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"context references undeclared prop :#{prop_name}. Declare the prop before calling context."
|
|
26
|
+
end
|
|
27
|
+
unless context_key.is_a?(Symbol)
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
_context_own.merge!(mappings)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def context_mappings
|
|
37
|
+
parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
|
|
38
|
+
parent.merge(_context_own)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def new(**kwargs)
|
|
42
|
+
mappings = context_mappings
|
|
43
|
+
unless mappings.empty?
|
|
44
|
+
ambient = Dex.context
|
|
45
|
+
mappings.each do |prop_name, context_key|
|
|
46
|
+
next if kwargs.key?(prop_name)
|
|
47
|
+
kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def _context_own
|
|
56
|
+
@_context_own_mappings ||= {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def _context_prop_declared?(name)
|
|
60
|
+
respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/dex/event/handler.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Dex
|
|
4
4
|
class Event
|
|
5
5
|
class Handler
|
|
6
|
+
include Dex::Executable
|
|
7
|
+
|
|
6
8
|
attr_reader :event
|
|
7
9
|
|
|
8
10
|
def self.on(*event_classes)
|
|
@@ -36,7 +38,7 @@ module Dex
|
|
|
36
38
|
def self._event_handle(event)
|
|
37
39
|
instance = new
|
|
38
40
|
instance.instance_variable_set(:@event, event)
|
|
39
|
-
instance.
|
|
41
|
+
instance.send(:call)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
|
|
@@ -69,6 +71,23 @@ module Dex
|
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
use TransactionWrapper
|
|
75
|
+
use CallbackWrapper
|
|
76
|
+
|
|
77
|
+
transaction false
|
|
78
|
+
private :call
|
|
79
|
+
|
|
80
|
+
# Guard must be defined after `include Executable` (which defines #call).
|
|
81
|
+
def self.method_added(method_name)
|
|
82
|
+
super
|
|
83
|
+
|
|
84
|
+
if method_name == :call
|
|
85
|
+
raise ArgumentError, "#{name || "Handler"} must not define #call — define #perform instead"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private :perform if method_name == :perform
|
|
89
|
+
end
|
|
90
|
+
|
|
72
91
|
def perform
|
|
73
92
|
raise NotImplementedError, "#{self.class.name} must implement #perform"
|
|
74
93
|
end
|
data/lib/dex/event.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "set"
|
|
4
|
-
|
|
5
3
|
# Modules loaded before class body (no reference to Dex::Event needed)
|
|
6
4
|
require_relative "event/execution_state"
|
|
7
5
|
require_relative "event/metadata"
|
|
@@ -17,6 +15,7 @@ module Dex
|
|
|
17
15
|
|
|
18
16
|
include PropsSetup
|
|
19
17
|
include TypeCoercion
|
|
18
|
+
include ContextSetup
|
|
20
19
|
|
|
21
20
|
def self._warn(message)
|
|
22
21
|
Dex.warn("Event: #{message}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module Executable
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.include(Dex::Settings)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def inherited(subclass)
|
|
12
|
+
subclass.instance_variable_set(:@_pipeline, pipeline.dup)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def pipeline
|
|
17
|
+
@_pipeline ||= Pipeline.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
|
|
21
|
+
step_name = as || _derive_step_name(mod)
|
|
22
|
+
wrap_method = wrap || :"_#{step_name}_wrap"
|
|
23
|
+
pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
|
|
24
|
+
include mod
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def _derive_step_name(mod)
|
|
30
|
+
base = mod.name&.split("::")&.last
|
|
31
|
+
raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
|
|
32
|
+
|
|
33
|
+
base.sub(/Wrapper\z/, "")
|
|
34
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
35
|
+
.downcase
|
|
36
|
+
.to_sym
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
self.class.pipeline.execute(self) { perform }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -21,7 +21,9 @@ module Dex
|
|
|
21
21
|
|
|
22
22
|
def enqueue_direct_job
|
|
23
23
|
job = apply_options(Operation::DirectJob)
|
|
24
|
-
|
|
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
|
-
|
|
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,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
|