easyop 0.1.2 → 0.1.4

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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Subscribes an operation class to handle domain events.
6
+ #
7
+ # The handler operation receives the event payload merged into ctx, plus
8
+ # ctx.event (the Easyop::Events::Event object itself for sync dispatch).
9
+ #
10
+ # Basic usage:
11
+ #
12
+ # class SendOrderConfirmation < ApplicationOperation
13
+ # plugin Easyop::Plugins::EventHandlers
14
+ #
15
+ # on "order.placed"
16
+ #
17
+ # def call
18
+ # event = ctx.event # Easyop::Events::Event
19
+ # order_id = ctx.order_id # payload keys merged into ctx
20
+ # OrderMailer.confirm(order_id).deliver_later
21
+ # end
22
+ # end
23
+ #
24
+ # Async dispatch (requires Easyop::Plugins::Async also installed):
25
+ #
26
+ # class IndexOrder < ApplicationOperation
27
+ # plugin Easyop::Plugins::Async, queue: "indexing"
28
+ # plugin Easyop::Plugins::EventHandlers
29
+ #
30
+ # on "order.*", async: true
31
+ # on "inventory.**", async: true, queue: "low"
32
+ #
33
+ # def call
34
+ # # For async dispatch ctx.event_data is a Hash (serialized for ActiveJob).
35
+ # # Reconstruct if needed: Easyop::Events::Event.new(**ctx.event_data)
36
+ # SearchIndex.reindex(ctx.order_id)
37
+ # end
38
+ # end
39
+ #
40
+ # Wildcard patterns:
41
+ # "order.*" — matches order.placed, order.shipped (not order.payment.failed)
42
+ # "warehouse.**" — matches warehouse.stock.updated, warehouse.zone.moved, etc.
43
+ module EventHandlers
44
+ def self.install(base, **_options)
45
+ base.extend(ClassMethods)
46
+ end
47
+
48
+ module ClassMethods
49
+ # Subscribe this operation to events matching +pattern+.
50
+ #
51
+ # Registration happens at class-load time and is bound to the bus that is
52
+ # active when the class is evaluated. Configure the bus before loading
53
+ # handler classes (e.g. in a Rails initializer that runs before autoloading).
54
+ #
55
+ # @param pattern [String, Regexp] event name or glob
56
+ # @param async [Boolean] enqueue via call_async (requires Async plugin)
57
+ # @param options [Hash] e.g. queue: "low" (overrides Async default)
58
+ def on(pattern, async: false, **options)
59
+ _event_handler_registrations << { pattern: pattern, async: async, options: options }
60
+
61
+ Easyop::Events::Registry.register_handler(
62
+ pattern: pattern,
63
+ handler_class: self,
64
+ async: async,
65
+ **options
66
+ )
67
+ end
68
+
69
+ # @api private — list of registrations declared on this class
70
+ def _event_handler_registrations
71
+ @_event_handler_registrations ||= []
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Emits domain events after an operation completes.
6
+ #
7
+ # Install on a base operation class to inherit into all subclasses:
8
+ #
9
+ # class ApplicationOperation
10
+ # include Easyop::Operation
11
+ # plugin Easyop::Plugins::Events
12
+ # end
13
+ #
14
+ # Then declare events on individual operations:
15
+ #
16
+ # class PlaceOrder < ApplicationOperation
17
+ # emits "order.placed", on: :success, payload: [:order_id, :total]
18
+ # emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
19
+ # emits "order.attempted", on: :always
20
+ #
21
+ # def call
22
+ # ctx.order_id = Order.create!(ctx.to_h).id
23
+ # end
24
+ # end
25
+ #
26
+ # Plugin options:
27
+ # bus: [Bus::Base, nil] per-class bus override (default: global registry bus)
28
+ # metadata: [Hash, Proc, nil] extra metadata merged into every event from this class
29
+ #
30
+ # `emits` DSL options:
31
+ # on: [:success (default), :failure, :always]
32
+ # payload: [Proc, Array, nil] Proc receives ctx; Array slices ctx keys; nil = full ctx.to_h
33
+ # guard: [Proc, nil] extra condition — event only fires if truthy
34
+ module Events
35
+ def self.install(base, bus: nil, metadata: nil, **_options)
36
+ base.extend(ClassMethods)
37
+ base.prepend(RunWrapper)
38
+ base.instance_variable_set(:@_events_bus, bus)
39
+ base.instance_variable_set(:@_events_metadata, metadata)
40
+ end
41
+
42
+ module ClassMethods
43
+ # Declare an event this operation emits after execution.
44
+ #
45
+ # @example
46
+ # emits "order.placed", on: :success, payload: [:order_id]
47
+ # emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
48
+ #
49
+ # @param name [String]
50
+ # @param on [Symbol] :success (default), :failure, or :always
51
+ # @param payload [Proc, Array, nil]
52
+ # @param guard [Proc, nil] optional condition — fires only when truthy
53
+ def emits(name, on: :success, payload: nil, guard: nil)
54
+ _emitted_events << { name: name.to_s, on: on, payload: payload, guard: guard }
55
+ end
56
+
57
+ # @api private — inheritable list of emits declarations
58
+ def _emitted_events
59
+ @_emitted_events ||= _inherited_emitted_events
60
+ end
61
+
62
+ # @api private — bus for this class (falls back to superclass, then global registry)
63
+ def _events_bus
64
+ if instance_variable_defined?(:@_events_bus)
65
+ @_events_bus
66
+ elsif superclass.respond_to?(:_events_bus)
67
+ superclass._events_bus
68
+ end
69
+ end
70
+
71
+ # @api private — metadata for this class (falls back to superclass)
72
+ def _events_metadata
73
+ if instance_variable_defined?(:@_events_metadata)
74
+ @_events_metadata
75
+ elsif superclass.respond_to?(:_events_metadata)
76
+ superclass._events_metadata
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def _inherited_emitted_events
83
+ superclass.respond_to?(:_emitted_events) ? superclass._emitted_events.dup : []
84
+ end
85
+ end
86
+
87
+ module RunWrapper
88
+ # Wraps _easyop_run to publish declared events in an ensure block.
89
+ # Events fire AFTER the operation completes — success, failure, or exception.
90
+ def _easyop_run(ctx, raise_on_failure:)
91
+ super
92
+ ensure
93
+ _events_publish_all(ctx)
94
+ end
95
+
96
+ private
97
+
98
+ def _events_publish_all(ctx)
99
+ declarations = self.class._emitted_events
100
+ return if declarations.empty?
101
+
102
+ bus = self.class._events_bus || Easyop::Events::Registry.bus
103
+
104
+ declarations.each do |decl|
105
+ next unless _events_should_fire?(decl, ctx)
106
+
107
+ event = _events_build_event(decl, ctx)
108
+ bus.publish(event)
109
+ rescue StandardError
110
+ # Individual publish failures must never crash the operation.
111
+ # Log if Rails is available.
112
+ _events_log_warning($ERROR_INFO)
113
+ end
114
+ end
115
+
116
+ def _events_should_fire?(decl, ctx)
117
+ case decl[:on]
118
+ when :success then return false unless ctx.success?
119
+ when :failure then return false unless ctx.failure?
120
+ when :always then nil # always fire
121
+ end
122
+
123
+ guard = decl[:guard]
124
+ guard ? guard.call(ctx) : true
125
+ end
126
+
127
+ def _events_build_event(decl, ctx)
128
+ Easyop::Events::Event.new(
129
+ name: decl[:name],
130
+ payload: _events_extract_payload(decl[:payload], ctx),
131
+ metadata: _events_build_metadata(ctx),
132
+ source: self.class.name
133
+ )
134
+ end
135
+
136
+ def _events_extract_payload(payload_spec, ctx)
137
+ case payload_spec
138
+ when Proc then payload_spec.call(ctx)
139
+ when Array then ctx.slice(*payload_spec)
140
+ when nil then ctx.to_h
141
+ else payload_spec
142
+ end
143
+ end
144
+
145
+ def _events_build_metadata(ctx)
146
+ meta = self.class._events_metadata
147
+ case meta
148
+ when Proc then meta.call(ctx)
149
+ when Hash then meta.dup
150
+ else {}
151
+ end
152
+ end
153
+
154
+ def _events_log_warning(err)
155
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
156
+
157
+ Rails.logger.warn "[EasyOp::Events] Failed to publish event: #{err.message}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Easyop
4
6
  module Plugins
5
7
  # Records operation executions to a database model.
@@ -18,6 +20,15 @@ module Easyop
18
20
  # duration_ms :float
19
21
  # performed_at :datetime, null: false
20
22
  #
23
+ # Optional flow-tracing columns:
24
+ # root_reference_id :string # shared across entire execution tree
25
+ # reference_id :string # unique to this operation execution
26
+ # parent_operation_name :string # class name of the direct parent
27
+ # parent_reference_id :string # reference_id of the direct parent
28
+ #
29
+ # Optional result column:
30
+ # result_data :text # stored as JSON — selected ctx keys after call
31
+ #
21
32
  # Opt out per operation class:
22
33
  # class MyOp < ApplicationOperation
23
34
  # recording false
@@ -26,15 +37,24 @@ module Easyop
26
37
  # Options:
27
38
  # model: (required) ActiveRecord class
28
39
  # record_params: true pass false to skip params serialization
40
+ # record_result: nil configure result capture at plugin level (Hash/Proc/Symbol)
29
41
  module Recording
30
42
  # Sensitive keys scrubbed from params_data before persisting.
31
43
  SCRUBBED_KEYS = %i[password password_confirmation token secret api_key].freeze
32
44
 
33
- def self.install(base, model:, record_params: true, **_options)
45
+ # Internal ctx keys used for flow tracing — excluded from params_data.
46
+ INTERNAL_CTX_KEYS = %i[
47
+ __recording_root_reference_id
48
+ __recording_parent_operation_name
49
+ __recording_parent_reference_id
50
+ ].freeze
51
+
52
+ def self.install(base, model:, record_params: true, record_result: nil, **_options)
34
53
  base.extend(ClassMethods)
35
54
  base.prepend(RunWrapper)
36
- base.instance_variable_set(:@_recording_model, model)
37
- base.instance_variable_set(:@_recording_record_params, record_params)
55
+ base.instance_variable_set(:@_recording_model, model)
56
+ base.instance_variable_set(:@_recording_record_params, record_params)
57
+ base.instance_variable_set(:@_recording_record_result, record_result)
38
58
  end
39
59
 
40
60
  module ClassMethods
@@ -62,6 +82,33 @@ module Easyop
62
82
  true
63
83
  end
64
84
  end
85
+
86
+ # DSL for capturing result data after the operation runs.
87
+ # Three forms:
88
+ # record_result attrs: :key # one or more ctx keys
89
+ # record_result { |ctx| { k: ctx.k } } # block
90
+ # record_result :build_result # private instance method name
91
+ def record_result(value = nil, attrs: nil, &block)
92
+ @_recording_record_result = if block
93
+ block
94
+ elsif attrs
95
+ { attrs: attrs }
96
+ elsif value.is_a?(Symbol)
97
+ value
98
+ else
99
+ value
100
+ end
101
+ end
102
+
103
+ def _recording_record_result_config
104
+ if instance_variable_defined?(:@_recording_record_result)
105
+ @_recording_record_result
106
+ elsif superclass.respond_to?(:_recording_record_result_config)
107
+ superclass._recording_record_result_config
108
+ else
109
+ nil
110
+ end
111
+ end
65
112
  end
66
113
 
67
114
  module RunWrapper
@@ -70,6 +117,23 @@ module Easyop
70
117
  return super unless (model = self.class._recording_model)
71
118
  return super unless self.class.name # skip anonymous classes
72
119
 
120
+ # -- Flow tracing --
121
+ # Each operation gets its own reference_id. The root_reference_id is
122
+ # shared across the entire execution tree via ctx (set once, inherited).
123
+ reference_id = SecureRandom.uuid
124
+ root_reference_id = ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
125
+
126
+ # Read current parent context — these become THIS operation's parent fields.
127
+ parent_operation_name = ctx[:__recording_parent_operation_name]
128
+ parent_reference_id = ctx[:__recording_parent_reference_id]
129
+
130
+ # Set THIS operation as the parent for any children that run inside super.
131
+ # Save the previous values so we can restore them after (for siblings).
132
+ prev_parent_name = parent_operation_name
133
+ prev_parent_id = parent_reference_id
134
+ ctx[:__recording_parent_operation_name] = self.class.name
135
+ ctx[:__recording_parent_reference_id] = reference_id
136
+
73
137
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
138
  super
75
139
  ensure
@@ -78,22 +142,37 @@ module Easyop
78
142
  # branch the tap block would be skipped and failures inside flows would
79
143
  # never be persisted.
80
144
  if start
145
+ # Restore parent context so sibling steps see the correct parent.
146
+ ctx[:__recording_parent_operation_name] = prev_parent_name
147
+ ctx[:__recording_parent_reference_id] = prev_parent_id
148
+
81
149
  ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
82
- _recording_persist!(ctx, model, ms)
150
+ _recording_persist!(ctx, model, ms,
151
+ root_reference_id: root_reference_id,
152
+ reference_id: reference_id,
153
+ parent_operation_name: parent_operation_name,
154
+ parent_reference_id: parent_reference_id)
83
155
  end
84
156
  end
85
157
 
86
158
  private
87
159
 
88
- def _recording_persist!(ctx, model, duration_ms)
160
+ def _recording_persist!(ctx, model, duration_ms,
161
+ root_reference_id: nil, reference_id: nil,
162
+ parent_operation_name: nil, parent_reference_id: nil)
89
163
  attrs = {
90
- operation_name: self.class.name,
91
- success: ctx.success?,
92
- error_message: ctx.error,
93
- performed_at: Time.current,
94
- duration_ms: duration_ms
164
+ operation_name: self.class.name,
165
+ success: ctx.success?,
166
+ error_message: ctx.error,
167
+ performed_at: Time.current,
168
+ duration_ms: duration_ms,
169
+ root_reference_id: root_reference_id,
170
+ reference_id: reference_id,
171
+ parent_operation_name: parent_operation_name,
172
+ parent_reference_id: parent_reference_id
95
173
  }
96
174
  attrs[:params_data] = _recording_safe_params(ctx) if self.class._recording_record_params?
175
+ attrs[:result_data] = _recording_safe_result(ctx) if self.class._recording_record_result_config
97
176
 
98
177
  # Only write columns the model actually has
99
178
  safe = attrs.select { |k, _| model.column_names.include?(k.to_s) }
@@ -104,13 +183,35 @@ module Easyop
104
183
 
105
184
  def _recording_safe_params(ctx)
106
185
  ctx.to_h
107
- .except(*SCRUBBED_KEYS)
186
+ .except(*SCRUBBED_KEYS, *INTERNAL_CTX_KEYS)
108
187
  .transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
109
188
  .to_json
110
189
  rescue
111
190
  nil
112
191
  end
113
192
 
193
+ def _recording_safe_result(ctx)
194
+ config = self.class._recording_record_result_config
195
+ return nil unless config
196
+
197
+ raw = case config
198
+ when Hash
199
+ keys = Array(config[:attrs])
200
+ keys.each_with_object({}) { |k, h| h[k] = ctx[k] }
201
+ when Proc
202
+ config.call(ctx)
203
+ when Symbol
204
+ send(config)
205
+ end
206
+
207
+ return nil unless raw.is_a?(Hash)
208
+
209
+ raw.transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
210
+ .to_json
211
+ rescue
212
+ nil
213
+ end
214
+
114
215
  def _recording_warn(err)
115
216
  return unless defined?(Rails) && Rails.respond_to?(:logger)
116
217
  Rails.logger.warn "[EasyOp::Recording] Failed to record #{self.class.name}: #{err.message}"
data/lib/easyop/schema.rb CHANGED
@@ -161,7 +161,7 @@ module Easyop
161
161
  if Easyop.config.strict_types
162
162
  ctx.fail!(error: msg, errors: ctx.errors.merge(field.name => msg))
163
163
  else
164
- warn "[Easyop] #{msg}"
164
+ $stderr.puts "[Easyop] #{msg}"
165
165
  end
166
166
  end
167
167
  end
@@ -1,3 +1,3 @@
1
1
  module Easyop
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
3
3
  end
data/lib/easyop.rb CHANGED
@@ -18,6 +18,17 @@ require_relative "easyop/flow"
18
18
  # require "easyop/plugins/recording"
19
19
  # require "easyop/plugins/async"
20
20
 
21
+ # Domain event plugins — require together or individually:
22
+ # require "easyop/events/event"
23
+ # require "easyop/events/bus"
24
+ # require "easyop/events/bus/memory"
25
+ # require "easyop/events/bus/active_support_notifications"
26
+ # require "easyop/events/bus/custom"
27
+ # require "easyop/events/bus/adapter" # inherit this to build a custom bus
28
+ # require "easyop/events/registry"
29
+ # require "easyop/plugins/events"
30
+ # require "easyop/plugins/event_handlers"
31
+
21
32
  module Easyop
22
33
  # Convenience: inherit from this instead of including Easyop::Operation
23
34
  # when you want a common base class for all your operations.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easyop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Niemczyk
@@ -53,12 +53,21 @@ files:
53
53
  - lib/easyop.rb
54
54
  - lib/easyop/configuration.rb
55
55
  - lib/easyop/ctx.rb
56
+ - lib/easyop/events/bus.rb
57
+ - lib/easyop/events/bus/active_support_notifications.rb
58
+ - lib/easyop/events/bus/adapter.rb
59
+ - lib/easyop/events/bus/custom.rb
60
+ - lib/easyop/events/bus/memory.rb
61
+ - lib/easyop/events/event.rb
62
+ - lib/easyop/events/registry.rb
56
63
  - lib/easyop/flow.rb
57
64
  - lib/easyop/flow_builder.rb
58
65
  - lib/easyop/hooks.rb
59
66
  - lib/easyop/operation.rb
60
67
  - lib/easyop/plugins/async.rb
61
68
  - lib/easyop/plugins/base.rb
69
+ - lib/easyop/plugins/event_handlers.rb
70
+ - lib/easyop/plugins/events.rb
62
71
  - lib/easyop/plugins/instrumentation.rb
63
72
  - lib/easyop/plugins/recording.rb
64
73
  - lib/easyop/plugins/transactional.rb