dexkit 0.1.0 → 0.2.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.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ # Lazy-loaded ActiveJob processor (same pattern as Operation::DirectJob)
6
+ def self.const_missing(name)
7
+ return super unless name == :Processor && defined?(ActiveJob::Base)
8
+
9
+ const_set(:Processor, Class.new(ActiveJob::Base) do
10
+ def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, context: nil, attempt_number: 1)
11
+ restore_context(context)
12
+
13
+ handler = Object.const_get(handler_class)
14
+ retry_config = handler._event_handler_retry_config
15
+
16
+ Dex::Event::Trace.restore(trace) do
17
+ handler._event_handle_from_payload(event_class, payload, metadata)
18
+ end
19
+ rescue => _e
20
+ if retry_config && attempt_number <= retry_config[:count]
21
+ delay = compute_delay(retry_config, attempt_number)
22
+ self.class.set(wait: delay).perform_later(
23
+ handler_class: handler_class,
24
+ event_class: event_class,
25
+ payload: payload,
26
+ metadata: metadata,
27
+ trace: trace,
28
+ context: context,
29
+ attempt_number: attempt_number + 1
30
+ )
31
+ else
32
+ raise
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def restore_context(context)
39
+ return unless context
40
+
41
+ restorer = Dex.configuration.restore_event_context
42
+ return unless restorer
43
+
44
+ restorer.call(context)
45
+ rescue => e
46
+ Dex::Event._warn("restore_event_context failed: #{e.message}")
47
+ end
48
+
49
+ def compute_delay(config, attempt)
50
+ wait = config[:wait]
51
+ case wait
52
+ when Numeric then wait
53
+ when Proc then wait.call(attempt)
54
+ else
55
+ 2**(attempt - 1) # exponential backoff
56
+ end
57
+ end
58
+ end)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module Suppression
6
+ SUPPRESSED_KEY = :_dex_event_suppressed
7
+
8
+ class << self
9
+ include ExecutionState
10
+
11
+ def suppress(*classes, &block)
12
+ previous = _suppressed_set
13
+ new_set = previous.dup
14
+ if classes.empty?
15
+ new_set << :all
16
+ else
17
+ classes.each do |klass|
18
+ Event.validate_event_class!(klass)
19
+ new_set << klass
20
+ end
21
+ end
22
+ _set_suppressed(new_set)
23
+ yield
24
+ ensure
25
+ _set_suppressed(previous)
26
+ end
27
+
28
+ def suppressed?(event_class)
29
+ set = _suppressed_set
30
+ set.include?(:all) || set.any? { |k| k != :all && event_class <= k }
31
+ end
32
+
33
+ def clear!
34
+ _execution_state[SUPPRESSED_KEY] = Set.new
35
+ end
36
+
37
+ private
38
+
39
+ def _suppressed_set
40
+ _execution_state[SUPPRESSED_KEY] ||= Set.new
41
+ end
42
+
43
+ def _set_suppressed(set)
44
+ _execution_state[SUPPRESSED_KEY] = set
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module Trace
6
+ STACK_KEY = :_dex_event_trace_stack
7
+
8
+ class << self
9
+ include ExecutionState
10
+
11
+ def with_event(event, &block)
12
+ stack = _stack
13
+ stack.push(event.trace_frame)
14
+ yield
15
+ ensure
16
+ stack.pop
17
+ end
18
+
19
+ def current_event_id
20
+ _stack.last&.dig(:id)
21
+ end
22
+
23
+ def current_trace_id
24
+ _stack.last&.dig(:trace_id)
25
+ end
26
+
27
+ def dump
28
+ frame = _stack.last
29
+ return nil unless frame
30
+
31
+ { id: frame[:id], trace_id: frame[:trace_id] }
32
+ end
33
+
34
+ def restore(data, &block)
35
+ return yield unless data
36
+
37
+ stack = _stack
38
+ stack.push(data)
39
+ yield
40
+ ensure
41
+ stack.pop if data
42
+ end
43
+
44
+ def clear!
45
+ _execution_state[STACK_KEY] = []
46
+ end
47
+
48
+ private
49
+
50
+ def _stack
51
+ _execution_state[STACK_KEY] ||= []
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/dex/event.rb ADDED
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Modules loaded before class body (no reference to Dex::Event needed)
6
+ require_relative "event/execution_state"
7
+ require_relative "event/metadata"
8
+ require_relative "event/trace"
9
+ require_relative "event/suppression"
10
+
11
+ module Dex
12
+ class Event
13
+ RESERVED_PROP_NAMES = %i[
14
+ id timestamp trace_id caused_by_id caused_by
15
+ context publish metadata sync
16
+ ].to_set.freeze
17
+
18
+ include PropsSetup
19
+ include TypeCoercion
20
+
21
+ def self._warn(message)
22
+ Dex.warn("Event: #{message}")
23
+ end
24
+
25
+ def self.validate_event_class!(klass)
26
+ return if klass.is_a?(Class) && klass < Dex::Event
27
+
28
+ raise ArgumentError, "#{klass.inspect} is not a Dex::Event subclass"
29
+ end
30
+
31
+ # --- Instance ---
32
+
33
+ attr_reader :metadata
34
+
35
+ def after_initialize
36
+ @metadata = Metadata.build
37
+ freeze
38
+ end
39
+
40
+ # Metadata delegates
41
+ def id = metadata.id
42
+ def timestamp = metadata.timestamp
43
+ def trace_id = metadata.trace_id
44
+ def caused_by_id = metadata.caused_by_id
45
+ def context = metadata.context
46
+ def trace_frame = { id: id, trace_id: trace_id }
47
+
48
+ # Publishing
49
+ def publish(sync: false)
50
+ Bus.publish(self, sync: sync)
51
+ end
52
+
53
+ def self.publish(sync: false, caused_by: nil, **kwargs)
54
+ if caused_by
55
+ Trace.with_event(caused_by) do
56
+ new(**kwargs).publish(sync: sync)
57
+ end
58
+ else
59
+ new(**kwargs).publish(sync: sync)
60
+ end
61
+ end
62
+
63
+ # Tracing
64
+ def trace(&block)
65
+ Trace.with_event(self, &block)
66
+ end
67
+
68
+ # Suppression
69
+ def self.suppress(*classes, &block)
70
+ Suppression.suppress(*classes, &block)
71
+ end
72
+
73
+ # Serialization
74
+ def as_json
75
+ {
76
+ "type" => self.class.name,
77
+ "payload" => _props_as_json,
78
+ "metadata" => metadata.as_json
79
+ }
80
+ end
81
+ end
82
+ end
83
+
84
+ # Classes loaded after Event is defined (they reference Dex::Event)
85
+ require_relative "event/bus"
86
+ require_relative "event/handler"
87
+ require_relative "event/processor"
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module TestHelpers
6
+ def assert_event_published(event_class, msg: nil, **expected_props)
7
+ matching = _dex_find_published_events(event_class, expected_props)
8
+ assert matching.any?,
9
+ msg || _dex_event_published_failure_message(event_class, expected_props)
10
+ end
11
+
12
+ def refute_event_published(event_class = nil, msg: nil, **expected_props)
13
+ if event_class.nil?
14
+ assert _dex_published_events.empty?,
15
+ msg || "Expected no events published, but #{_dex_published_events.size} were:\n#{_dex_event_list}"
16
+ else
17
+ matching = _dex_find_published_events(event_class, expected_props)
18
+ assert matching.empty?,
19
+ msg || "Expected no #{event_class.name || event_class} events published, but found #{matching.size}"
20
+ end
21
+ end
22
+
23
+ def assert_event_count(event_class, count, msg: nil)
24
+ matching = _dex_find_published_events(event_class, {})
25
+ assert_equal count, matching.size,
26
+ msg || "Expected #{count} #{event_class.name || event_class} events, got #{matching.size}"
27
+ end
28
+
29
+ def assert_event_trace(parent, child, msg: nil)
30
+ assert_equal parent.id, child.caused_by_id,
31
+ msg || "Expected child event to be caused by parent (caused_by_id mismatch)"
32
+ end
33
+
34
+ def assert_same_trace(*events, msg: nil)
35
+ trace_ids = events.map(&:trace_id).uniq
36
+ assert_equal 1, trace_ids.size,
37
+ msg || "Expected all events to share the same trace_id, got: #{trace_ids.inspect}"
38
+ end
39
+
40
+ private
41
+
42
+ def _dex_find_published_events(event_class, expected_props)
43
+ _dex_published_events.select do |event|
44
+ next false unless event.is_a?(event_class)
45
+
46
+ expected_props.all? do |key, value|
47
+ event.respond_to?(key) && event.public_send(key) == value
48
+ end
49
+ end
50
+ end
51
+
52
+ def _dex_event_published_failure_message(event_class, expected_props)
53
+ name = event_class.name || event_class.to_s
54
+ if _dex_published_events.empty?
55
+ "Expected #{name} to be published, but no events were published"
56
+ else
57
+ msg = "Expected #{name} to be published"
58
+ msg += " with #{expected_props.inspect}" unless expected_props.empty?
59
+ msg + ", but only found:\n#{_dex_event_list}"
60
+ end
61
+ end
62
+
63
+ def _dex_event_list
64
+ _dex_published_events.map.with_index do |event, i|
65
+ " #{i + 1}. #{event.class.name || event.class}"
66
+ end.join("\n")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module EventTestWrapper
6
+ CAPTURING_KEY = :_dex_event_capturing
7
+ PUBLISHED_KEY = :_dex_event_published
8
+
9
+ @_installed = false
10
+
11
+ class << self
12
+ include ExecutionState
13
+
14
+ def install!
15
+ return if @_installed
16
+
17
+ Dex::Event::Bus.singleton_class.prepend(BusInterceptor)
18
+ @_installed = true
19
+ end
20
+
21
+ def installed?
22
+ @_installed
23
+ end
24
+
25
+ def capturing?
26
+ (_execution_state[CAPTURING_KEY] || 0) > 0
27
+ end
28
+
29
+ def begin_capture!
30
+ _execution_state[CAPTURING_KEY] = (_execution_state[CAPTURING_KEY] || 0) + 1
31
+ end
32
+
33
+ def end_capture!
34
+ depth = (_execution_state[CAPTURING_KEY] || 0) - 1
35
+ _execution_state[CAPTURING_KEY] = [depth, 0].max
36
+ end
37
+
38
+ def published_events
39
+ _execution_state[PUBLISHED_KEY] ||= []
40
+ end
41
+
42
+ def clear_published!
43
+ _execution_state[PUBLISHED_KEY] = []
44
+ end
45
+ end
46
+
47
+ module BusInterceptor
48
+ def publish(event, sync:)
49
+ if Dex::Event::EventTestWrapper.capturing?
50
+ return if Dex::Event::Suppression.suppressed?(event.class)
51
+
52
+ Dex::Event::EventTestWrapper.published_events << event
53
+ else
54
+ super(event, sync: true)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ module TestHelpers
61
+ def self.included(base)
62
+ EventTestWrapper.install!
63
+ end
64
+
65
+ def setup
66
+ super
67
+ EventTestWrapper.clear_published!
68
+ Dex::Event::Trace.clear!
69
+ Dex::Event::Suppression.clear!
70
+ end
71
+
72
+ def capture_events
73
+ EventTestWrapper.begin_capture!
74
+ yield
75
+ ensure
76
+ EventTestWrapper.end_capture!
77
+ end
78
+
79
+ private
80
+
81
+ def _dex_published_events
82
+ EventTestWrapper.published_events
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ require_relative "event_test_helpers/assertions"
@@ -9,41 +9,41 @@ module Dex
9
9
  end
10
10
 
11
11
  def call
12
- _async_ensure_active_job_loaded!
13
- if _async_use_record_strategy?
14
- _async_enqueue_record_job
12
+ ensure_active_job_loaded!
13
+ if use_record_strategy?
14
+ enqueue_record_job
15
15
  else
16
- _async_enqueue_direct_job
16
+ enqueue_direct_job
17
17
  end
18
18
  end
19
19
 
20
20
  private
21
21
 
22
- def _async_enqueue_direct_job
23
- job = _async_apply_options(Operation::DirectJob)
24
- job.perform_later(class_name: _async_operation_class_name, params: _async_serialized_params)
22
+ def enqueue_direct_job
23
+ job = apply_options(Operation::DirectJob)
24
+ job.perform_later(class_name: operation_class_name, params: serialized_params)
25
25
  end
26
26
 
27
- def _async_enqueue_record_job
27
+ def enqueue_record_job
28
28
  record = Dex.record_backend.create_record(
29
- name: _async_operation_class_name,
30
- params: _async_serialized_params,
29
+ name: operation_class_name,
30
+ params: serialized_params,
31
31
  status: "pending"
32
32
  )
33
33
  begin
34
- job = _async_apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: _async_operation_class_name, record_id: record.id)
34
+ job = apply_options(Operation::RecordJob)
35
+ job.perform_later(class_name: operation_class_name, record_id: record.id)
36
36
  rescue => e
37
37
  begin
38
38
  record.destroy
39
39
  rescue => destroy_error
40
- _async_log_warning("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
40
+ Dex.warn("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
41
41
  end
42
42
  raise e
43
43
  end
44
44
  end
45
45
 
46
- def _async_use_record_strategy?
46
+ def use_record_strategy?
47
47
  return false unless Dex.record_backend
48
48
  return false unless @operation.class.name
49
49
 
@@ -54,54 +54,48 @@ module Dex
54
54
  true
55
55
  end
56
56
 
57
- def _async_apply_options(job_class)
57
+ def apply_options(job_class)
58
58
  options = {}
59
- options[:queue] = _async_queue if _async_queue
60
- options[:wait_until] = _async_scheduled_at if _async_scheduled_at
61
- options[:wait] = _async_scheduled_in if _async_scheduled_in
59
+ options[:queue] = queue if queue
60
+ options[:wait_until] = scheduled_at if scheduled_at
61
+ options[:wait] = scheduled_in if scheduled_in
62
62
  options.empty? ? job_class : job_class.set(**options)
63
63
  end
64
64
 
65
- def _async_ensure_active_job_loaded!
65
+ def ensure_active_job_loaded!
66
66
  return if defined?(ActiveJob::Base)
67
67
 
68
68
  raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
69
  end
70
70
 
71
- def _async_merged_options
71
+ def merged_options
72
72
  @operation.class.settings_for(:async).merge(@runtime_options)
73
73
  end
74
74
 
75
- def _async_queue = _async_merged_options[:queue]
76
- def _async_scheduled_at = _async_merged_options[:at]
77
- def _async_scheduled_in = _async_merged_options[:in]
78
- def _async_operation_class_name = @operation.class.name
75
+ def queue = merged_options[:queue]
76
+ def scheduled_at = merged_options[:at]
77
+ def scheduled_in = merged_options[:in]
78
+ def operation_class_name = @operation.class.name
79
79
 
80
- def _async_serialized_params
81
- @_async_serialized_params ||= begin
80
+ def serialized_params
81
+ @serialized_params ||= begin
82
82
  hash = @operation._props_as_json
83
- _async_validate_serializable!(hash)
83
+ validate_serializable!(hash)
84
84
  hash
85
85
  end
86
86
  end
87
87
 
88
- def _async_log_warning(message)
89
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
90
- Rails.logger.warn "[Dex] #{message}"
91
- end
92
- end
93
-
94
- def _async_validate_serializable!(hash, path: "")
88
+ def validate_serializable!(hash, path: "")
95
89
  hash.each do |key, value|
96
90
  current = path.empty? ? key.to_s : "#{path}.#{key}"
97
91
  case value
98
92
  when String, Integer, Float, NilClass, TrueClass, FalseClass
99
93
  next
100
94
  when Hash
101
- _async_validate_serializable!(value, path: current)
95
+ validate_serializable!(value, path: current)
102
96
  when Array
103
97
  value.each_with_index do |v, i|
104
- _async_validate_serializable!({ i => v }, path: current)
98
+ validate_serializable!({ i => v }, path: current)
105
99
  end
106
100
  else
107
101
  raise ArgumentError,
@@ -2,33 +2,17 @@
2
2
 
3
3
  module Dex
4
4
  module AsyncWrapper
5
- ASYNC_KNOWN_OPTIONS = %i[queue in at].freeze
6
-
7
5
  module ClassMethods
8
6
  def async(**options)
9
- unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
10
- if unknown.any?
11
- raise ArgumentError,
12
- "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
13
- "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
14
- end
15
-
7
+ validate_options!(options, %i[queue in at], :async)
16
8
  set(:async, **options)
17
9
  end
18
10
  end
19
11
 
20
- def self.included(base)
21
- base.extend(ClassMethods)
22
- end
12
+ extend Dex::Concern
23
13
 
24
14
  def async(**options)
25
- unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
26
- if unknown.any?
27
- raise ArgumentError,
28
- "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
29
- "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
30
- end
31
-
15
+ self.class.validate_options!(options, %i[queue in at], :async)
32
16
  Operation::AsyncProxy.new(self, **options)
33
17
  end
34
18
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Dex
4
4
  module CallbackWrapper
5
- def self.included(base)
6
- base.extend(ClassMethods)
7
- end
5
+ extend Dex::Concern
8
6
 
9
7
  module ClassMethods
10
8
  def before(callable = nil, &block)
@@ -57,24 +55,22 @@ module Dex
57
55
  def _callback_wrap
58
56
  return yield unless self.class._callback_any?
59
57
 
60
- halted = nil
58
+ success_halt = nil
61
59
  result = _callback_run_around(self.class._callback_list(:around)) do
62
60
  _callback_run_before
63
- caught = catch(:_dex_halt) { yield }
64
- if caught.is_a?(Operation::Halt)
65
- if caught.success?
66
- halted = caught
67
- _callback_run_after
68
- caught.value
69
- else
70
- throw(:_dex_halt, caught)
71
- end
61
+ interceptor = Operation::HaltInterceptor.new { yield }
62
+ if interceptor.error?
63
+ interceptor.rethrow!
64
+ elsif interceptor.halted?
65
+ success_halt = interceptor
66
+ _callback_run_after
67
+ interceptor.result
72
68
  else
73
69
  _callback_run_after
74
- caught
70
+ interceptor.result
75
71
  end
76
72
  end
77
- throw(:_dex_halt, halted) if halted
73
+ success_halt&.rethrow!
78
74
  result
79
75
  end
80
76
 
@@ -11,7 +11,7 @@ module Dex
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
12
  def perform(class_name:, params:)
13
13
  klass = class_name.constantize
14
- klass.new(**klass.send(:_dex_coerce_serialized_hash, params)).call
14
+ klass.new(**klass.send(:_coerce_serialized_hash, params)).call
15
15
  end
16
16
  end)
17
17
  when :RecordJob
@@ -19,39 +19,33 @@ module Dex
19
19
  def perform(class_name:, record_id:)
20
20
  klass = class_name.constantize
21
21
  record = Dex.record_backend.find_record(record_id)
22
- params = klass.send(:_dex_coerce_serialized_hash, record.params || {})
22
+ params = klass.send(:_coerce_serialized_hash, record.params || {})
23
23
 
24
24
  op = klass.new(**params)
25
25
  op.instance_variable_set(:@_dex_record_id, record_id)
26
26
 
27
- _dex_update_status(record_id, status: "running")
27
+ update_status(record_id, status: "running")
28
28
  op.call
29
29
  rescue => e
30
- _dex_handle_failure(record_id, e)
30
+ handle_failure(record_id, e)
31
31
  raise
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- def _dex_update_status(record_id, **attributes)
36
+ def update_status(record_id, **attributes)
37
37
  Dex.record_backend.update_record(record_id, attributes)
38
38
  rescue => e
39
- _dex_log_warning("Failed to update record status: #{e.message}")
39
+ Dex.warn("Failed to update record status: #{e.message}")
40
40
  end
41
41
 
42
- def _dex_handle_failure(record_id, exception)
42
+ def handle_failure(record_id, exception)
43
43
  error_value = if exception.is_a?(Dex::Error)
44
44
  exception.code.to_s
45
45
  else
46
46
  exception.class.name
47
47
  end
48
- _dex_update_status(record_id, status: "failed", error: error_value)
49
- end
50
-
51
- def _dex_log_warning(message)
52
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
53
- Rails.logger.warn "[Dex] #{message}"
54
- end
48
+ update_status(record_id, status: "failed", error: error_value)
55
49
  end
56
50
  end)
57
51
  when :Job