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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -4
- data/README.md +390 -0
- data/lib/easyop/configuration.rb +8 -0
- data/lib/easyop/events/bus/active_support_notifications.rb +82 -0
- data/lib/easyop/events/bus/adapter.rb +119 -0
- data/lib/easyop/events/bus/custom.rb +51 -0
- data/lib/easyop/events/bus/memory.rb +64 -0
- data/lib/easyop/events/bus.rb +66 -0
- data/lib/easyop/events/event.rb +44 -0
- data/lib/easyop/events/registry.rb +128 -0
- data/lib/easyop/flow.rb +48 -0
- data/lib/easyop/plugins/event_handlers.rb +76 -0
- data/lib/easyop/plugins/events.rb +162 -0
- data/lib/easyop/plugins/recording.rb +112 -11
- data/lib/easyop/schema.rb +1 -1
- data/lib/easyop/version.rb +1 -1
- data/lib/easyop.rb +11 -0
- metadata +10 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
module Bus
|
|
6
|
+
# Bus adapter backed by ActiveSupport::Notifications.
|
|
7
|
+
#
|
|
8
|
+
# Requires activesupport (not auto-required). Raises LoadError if unavailable.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Easyop::Events::Registry.bus = :active_support
|
|
12
|
+
#
|
|
13
|
+
# # or manually:
|
|
14
|
+
# bus = Easyop::Events::Bus::ActiveSupportNotifications.new
|
|
15
|
+
# Easyop::Events::Registry.bus = bus
|
|
16
|
+
class ActiveSupportNotifications < Base
|
|
17
|
+
# Publish +event+ via ActiveSupport::Notifications.instrument.
|
|
18
|
+
# The full event hash (name, payload, metadata, timestamp, source) is passed
|
|
19
|
+
# as the AS notification payload.
|
|
20
|
+
#
|
|
21
|
+
# @param event [Easyop::Events::Event]
|
|
22
|
+
def publish(event)
|
|
23
|
+
_ensure_as!
|
|
24
|
+
::ActiveSupport::Notifications.instrument(event.name, event.to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Subscribe to events matching +pattern+ via ActiveSupport::Notifications.subscribe.
|
|
28
|
+
# Glob patterns are converted to Regexp before passing to AS.
|
|
29
|
+
#
|
|
30
|
+
# @param pattern [String, Regexp]
|
|
31
|
+
# @return [Object] AS subscription handle
|
|
32
|
+
def subscribe(pattern, &block)
|
|
33
|
+
_ensure_as!
|
|
34
|
+
|
|
35
|
+
as_pattern = _as_pattern(pattern)
|
|
36
|
+
|
|
37
|
+
::ActiveSupport::Notifications.subscribe(as_pattern) do |*args|
|
|
38
|
+
as_event = ::ActiveSupport::Notifications::Event.new(*args)
|
|
39
|
+
p = as_event.payload
|
|
40
|
+
|
|
41
|
+
# Reconstruct an Easyop::Events::Event from the AS notification payload.
|
|
42
|
+
easyop_event = Event.new(
|
|
43
|
+
name: (p[:name] || as_event.name).to_s,
|
|
44
|
+
payload: p[:payload] || {},
|
|
45
|
+
metadata: p[:metadata] || {},
|
|
46
|
+
timestamp: p[:timestamp],
|
|
47
|
+
source: p[:source]
|
|
48
|
+
)
|
|
49
|
+
block.call(easyop_event)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Unsubscribe using the handle returned by #subscribe.
|
|
54
|
+
# @param handle [Object] AS subscription object
|
|
55
|
+
def unsubscribe(handle)
|
|
56
|
+
_ensure_as!
|
|
57
|
+
::ActiveSupport::Notifications.unsubscribe(handle)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Convert a pattern for use with AS::Notifications.subscribe.
|
|
63
|
+
# AS accepts exact strings or Regexp (not globs), so convert globs first.
|
|
64
|
+
def _as_pattern(pattern)
|
|
65
|
+
case pattern
|
|
66
|
+
when Regexp then pattern
|
|
67
|
+
when String
|
|
68
|
+
pattern.include?("*") ? _glob_to_regex(pattern) : pattern
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def _ensure_as!
|
|
73
|
+
return if defined?(::ActiveSupport::Notifications)
|
|
74
|
+
|
|
75
|
+
raise LoadError,
|
|
76
|
+
"ActiveSupport::Notifications is required for this bus adapter. " \
|
|
77
|
+
"Add 'activesupport' to your Gemfile."
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
module Bus
|
|
6
|
+
# Inheritable base class for custom bus implementations.
|
|
7
|
+
#
|
|
8
|
+
# Subclass this (not +Bus::Base+) when building a transport adapter for
|
|
9
|
+
# an external message broker, pub/sub system, or any custom delivery
|
|
10
|
+
# mechanism. +Bus::Base+ defines the required interface and glob helpers.
|
|
11
|
+
# +Bus::Adapter+ adds two production-grade utilities on top:
|
|
12
|
+
#
|
|
13
|
+
# _safe_invoke(handler, event) — call a handler, swallow StandardError
|
|
14
|
+
# _compile_pattern(pattern) — glob/string → cached Regexp
|
|
15
|
+
#
|
|
16
|
+
# Both are protected so they are accessible in subclasses but not part
|
|
17
|
+
# of the external bus interface.
|
|
18
|
+
#
|
|
19
|
+
# == Minimum contract
|
|
20
|
+
#
|
|
21
|
+
# Override +#publish+ and +#subscribe+. Override +#unsubscribe+ if your
|
|
22
|
+
# transport supports subscription cancellation.
|
|
23
|
+
#
|
|
24
|
+
# == Example — minimal adapter
|
|
25
|
+
#
|
|
26
|
+
# class PrintBus < Easyop::Events::Bus::Adapter
|
|
27
|
+
# def initialize
|
|
28
|
+
# super
|
|
29
|
+
# @subs = []
|
|
30
|
+
# @mutex = Mutex.new
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# def publish(event)
|
|
34
|
+
# snap = @mutex.synchronize { @subs.dup }
|
|
35
|
+
# snap.each do |sub|
|
|
36
|
+
# _safe_invoke(sub[:handler], event) if _pattern_matches?(sub[:pattern], event.name)
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# def subscribe(pattern, &block)
|
|
41
|
+
# handle = { pattern: _compile_pattern(pattern), handler: block }
|
|
42
|
+
# @mutex.synchronize { @subs << handle }
|
|
43
|
+
# handle
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# def unsubscribe(handle)
|
|
47
|
+
# @mutex.synchronize { @subs.delete(handle) }
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# == Example — decorator (wraps another bus)
|
|
52
|
+
#
|
|
53
|
+
# class LoggingBus < Easyop::Events::Bus::Adapter
|
|
54
|
+
# def initialize(inner)
|
|
55
|
+
# super()
|
|
56
|
+
# @inner = inner
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# def publish(event)
|
|
60
|
+
# Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
|
|
61
|
+
# @inner.publish(event)
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
# def subscribe(pattern, &block)
|
|
65
|
+
# @inner.subscribe(pattern, &block)
|
|
66
|
+
# end
|
|
67
|
+
#
|
|
68
|
+
# def unsubscribe(handle)
|
|
69
|
+
# @inner.unsubscribe(handle)
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# Easyop::Events::Registry.bus = LoggingBus.new(Easyop::Events::Bus::Memory.new)
|
|
74
|
+
class Adapter < Base
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
# Call +handler+ with +event+, rescuing any StandardError.
|
|
78
|
+
#
|
|
79
|
+
# Use this inside your +#publish+ implementation so one broken handler
|
|
80
|
+
# never prevents other handlers from running and never surfaces an
|
|
81
|
+
# exception to the event producer.
|
|
82
|
+
#
|
|
83
|
+
# @param handler [#call]
|
|
84
|
+
# @param event [Easyop::Events::Event]
|
|
85
|
+
# @return [void]
|
|
86
|
+
def _safe_invoke(handler, event)
|
|
87
|
+
handler.call(event)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
# Intentionally swallowed — individual handler failures must not
|
|
90
|
+
# propagate to the caller or block remaining handlers.
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Compile +pattern+ into a +Regexp+, memoized per unique pattern value.
|
|
94
|
+
#
|
|
95
|
+
# Results are cached in a per-instance hash so glob→Regexp conversion
|
|
96
|
+
# happens only once regardless of how many events are published.
|
|
97
|
+
#
|
|
98
|
+
# String patterns without wildcards are anchored literally.
|
|
99
|
+
# Glob patterns follow the same rules as +Bus::Base#_glob_to_regex+:
|
|
100
|
+
# "*" — any single dot-separated segment
|
|
101
|
+
# "**" — any sequence of characters including dots
|
|
102
|
+
#
|
|
103
|
+
# @param pattern [String, Regexp]
|
|
104
|
+
# @return [Regexp]
|
|
105
|
+
def _compile_pattern(pattern)
|
|
106
|
+
return pattern if pattern.is_a?(Regexp)
|
|
107
|
+
|
|
108
|
+
@_pattern_cache ||= {}
|
|
109
|
+
@_pattern_cache[pattern] ||=
|
|
110
|
+
if pattern.include?("*")
|
|
111
|
+
_glob_to_regex(pattern)
|
|
112
|
+
else
|
|
113
|
+
Regexp.new("\\A#{Regexp.escape(pattern)}\\z")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
module Bus
|
|
6
|
+
# Wraps any user-supplied bus adapter.
|
|
7
|
+
#
|
|
8
|
+
# The adapter must respond to:
|
|
9
|
+
# #publish(event)
|
|
10
|
+
# #subscribe(pattern, &block)
|
|
11
|
+
#
|
|
12
|
+
# Optionally:
|
|
13
|
+
# #unsubscribe(handle)
|
|
14
|
+
#
|
|
15
|
+
# @example Wrapping a RabbitMQ adapter
|
|
16
|
+
# class MyRabbitBus
|
|
17
|
+
# def publish(event) = rabbit.publish(event.to_h, routing_key: event.name)
|
|
18
|
+
# def subscribe(pattern, &block) = rabbit.subscribe(pattern) { |msg| block.call(reconstruct(msg)) }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Easyop::Events::Registry.bus = MyRabbitBus.new
|
|
22
|
+
#
|
|
23
|
+
# @example Passing via Custom wrapper explicitly
|
|
24
|
+
# Easyop::Events::Registry.bus = Easyop::Events::Bus::Custom.new(MyRabbitBus.new)
|
|
25
|
+
class Custom < Base
|
|
26
|
+
# @param adapter [Object] must respond to #publish and #subscribe
|
|
27
|
+
# @raise [ArgumentError] if adapter does not meet the interface
|
|
28
|
+
def initialize(adapter)
|
|
29
|
+
unless adapter.respond_to?(:publish) && adapter.respond_to?(:subscribe)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Custom bus adapter must respond to #publish(event) and " \
|
|
32
|
+
"#subscribe(pattern, &block). Got: #{adapter.inspect}"
|
|
33
|
+
end
|
|
34
|
+
@adapter = adapter
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def publish(event)
|
|
38
|
+
@adapter.publish(event)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def subscribe(pattern, &block)
|
|
42
|
+
@adapter.subscribe(pattern, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unsubscribe(handle)
|
|
46
|
+
@adapter.unsubscribe(handle) if @adapter.respond_to?(:unsubscribe)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
module Bus
|
|
6
|
+
# In-process, synchronous bus. Default adapter — requires no external gems.
|
|
7
|
+
#
|
|
8
|
+
# Thread-safe: subscriptions are protected by a Mutex, and publish snapshots
|
|
9
|
+
# the subscriber list before calling handlers so the lock is not held
|
|
10
|
+
# during handler execution (prevents deadlocks).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# bus = Easyop::Events::Bus::Memory.new
|
|
14
|
+
# bus.subscribe("order.placed") { |e| puts e.name }
|
|
15
|
+
# bus.publish(Easyop::Events::Event.new(name: "order.placed"))
|
|
16
|
+
class Memory < Base
|
|
17
|
+
def initialize
|
|
18
|
+
@subscribers = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Deliver +event+ to all matching subscribers.
|
|
23
|
+
# Handlers are called outside the lock; failures in individual handlers
|
|
24
|
+
# are swallowed so other handlers still run.
|
|
25
|
+
#
|
|
26
|
+
# @param event [Easyop::Events::Event]
|
|
27
|
+
def publish(event)
|
|
28
|
+
subs = @mutex.synchronize { @subscribers.dup }
|
|
29
|
+
subs.each do |sub|
|
|
30
|
+
sub[:handler].call(event) if _pattern_matches?(sub[:pattern], event.name)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
# Individual handler failures must not prevent other handlers from running.
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register a handler block for events matching +pattern+.
|
|
37
|
+
#
|
|
38
|
+
# @param pattern [String, Regexp] exact name, glob, or Regexp
|
|
39
|
+
# @return [Hash] subscription handle (pass to #unsubscribe to remove)
|
|
40
|
+
def subscribe(pattern, &block)
|
|
41
|
+
entry = { pattern: pattern, handler: block }
|
|
42
|
+
@mutex.synchronize { @subscribers << entry }
|
|
43
|
+
entry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Remove a previously registered subscription.
|
|
47
|
+
# @param handle [Hash] the value returned by #subscribe
|
|
48
|
+
def unsubscribe(handle)
|
|
49
|
+
@mutex.synchronize { @subscribers.delete(handle) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Remove all subscriptions. Useful in tests.
|
|
53
|
+
def clear!
|
|
54
|
+
@mutex.synchronize { @subscribers.clear }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Integer] number of active subscriptions
|
|
58
|
+
def subscriber_count
|
|
59
|
+
@mutex.synchronize { @subscribers.size }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
# Namespace for bus adapters.
|
|
6
|
+
# All adapters inherit from Easyop::Events::Bus::Base.
|
|
7
|
+
module Bus
|
|
8
|
+
# Abstract base for bus adapters.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses must implement:
|
|
11
|
+
# #publish(event) — deliver event to matching subscribers
|
|
12
|
+
# #subscribe(pattern, &block) — register a handler block
|
|
13
|
+
#
|
|
14
|
+
# Optionally override:
|
|
15
|
+
# #unsubscribe(handle) — remove a subscription (handle is return value of #subscribe)
|
|
16
|
+
class Base
|
|
17
|
+
# @param event [Easyop::Events::Event]
|
|
18
|
+
def publish(event)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#publish must be implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param pattern [String, Regexp] event name, glob ("order.*"), or Regexp
|
|
23
|
+
# @yield [event] called when a matching event is published
|
|
24
|
+
# @return [Object] subscription handle (adapter-specific)
|
|
25
|
+
def subscribe(pattern, &block)
|
|
26
|
+
raise NotImplementedError, "#{self.class}#subscribe must be implemented"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Remove a subscription created by #subscribe.
|
|
30
|
+
# @param handle [Object] the value returned by #subscribe
|
|
31
|
+
def unsubscribe(handle)
|
|
32
|
+
# default no-op — adapters may override
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Returns true when +pattern+ matches +event_name+.
|
|
38
|
+
# Supports exact strings, glob patterns ("order.*", "order.**"), and Regexp.
|
|
39
|
+
#
|
|
40
|
+
# Glob rules:
|
|
41
|
+
# "*" — matches any segment that doesn't cross a dot
|
|
42
|
+
# "**" — matches any string including dots (greedy)
|
|
43
|
+
#
|
|
44
|
+
# @param pattern [String, Regexp]
|
|
45
|
+
# @param event_name [String]
|
|
46
|
+
def _pattern_matches?(pattern, event_name)
|
|
47
|
+
case pattern
|
|
48
|
+
when Regexp
|
|
49
|
+
pattern.match?(event_name)
|
|
50
|
+
when String
|
|
51
|
+
pattern.include?("*") ? _glob_to_regex(pattern).match?(event_name) : pattern == event_name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param glob [String] e.g. "order.*" or "warehouse.**"
|
|
56
|
+
# @return [Regexp]
|
|
57
|
+
def _glob_to_regex(glob)
|
|
58
|
+
escaped = Regexp.escape(glob)
|
|
59
|
+
.gsub("\\*\\*", ".+")
|
|
60
|
+
.gsub("\\*", "[^.]+")
|
|
61
|
+
Regexp.new("\\A#{escaped}\\z")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
# An immutable domain event value object.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# event = Easyop::Events::Event.new(
|
|
9
|
+
# name: "order.placed",
|
|
10
|
+
# payload: { order_id: 42, total: 9900 },
|
|
11
|
+
# source: "PlaceOrder"
|
|
12
|
+
# )
|
|
13
|
+
# event.name # => "order.placed"
|
|
14
|
+
# event.payload # => { order_id: 42, total: 9900 }
|
|
15
|
+
# event.frozen? # => true
|
|
16
|
+
class Event
|
|
17
|
+
attr_reader :name, :payload, :metadata, :timestamp, :source
|
|
18
|
+
|
|
19
|
+
# @param name [String] event name, e.g. "order.placed"
|
|
20
|
+
# @param payload [Hash] domain data extracted from ctx
|
|
21
|
+
# @param metadata [Hash] extra envelope data (correlation_id, etc.)
|
|
22
|
+
# @param timestamp [Time] defaults to Time.now
|
|
23
|
+
# @param source [String] class name of the emitting operation
|
|
24
|
+
def initialize(name:, payload: {}, metadata: {}, timestamp: nil, source: nil)
|
|
25
|
+
@name = name.to_s.freeze
|
|
26
|
+
@payload = payload.freeze
|
|
27
|
+
@metadata = metadata.freeze
|
|
28
|
+
@timestamp = (timestamp || Time.now).freeze
|
|
29
|
+
@source = source&.freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash] serializable hash representation
|
|
34
|
+
def to_h
|
|
35
|
+
{ name: @name, payload: @payload, metadata: @metadata,
|
|
36
|
+
timestamp: @timestamp, source: @source }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inspect
|
|
40
|
+
"#<Easyop::Events::Event name=#{@name.inspect} source=#{@source.inspect}>"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Events
|
|
5
|
+
# Thread-safe global registry for the event bus and handler subscriptions.
|
|
6
|
+
#
|
|
7
|
+
# The Registry is the coordination point between the Events plugin (producer)
|
|
8
|
+
# and the EventHandlers plugin (subscriber). Neither plugin references the other
|
|
9
|
+
# directly — they communicate only through the bus managed here.
|
|
10
|
+
#
|
|
11
|
+
# Configure the bus once at boot (e.g. in a Rails initializer):
|
|
12
|
+
#
|
|
13
|
+
# Easyop::Events::Registry.bus = :memory # default, in-process
|
|
14
|
+
# Easyop::Events::Registry.bus = :active_support # ActiveSupport::Notifications
|
|
15
|
+
# Easyop::Events::Registry.bus = MyRabbitBus.new # custom adapter
|
|
16
|
+
#
|
|
17
|
+
# Or via the global config:
|
|
18
|
+
#
|
|
19
|
+
# Easyop.configure { |c| c.event_bus = :active_support }
|
|
20
|
+
#
|
|
21
|
+
# IMPORTANT: Configure the bus BEFORE handler classes are loaded (before
|
|
22
|
+
# autoloading runs in Rails). Subscriptions are registered at class load time
|
|
23
|
+
# and are bound to whatever bus is active at that moment.
|
|
24
|
+
class Registry
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Set the global bus adapter.
|
|
29
|
+
#
|
|
30
|
+
# @param bus_or_symbol [:memory, :active_support, Bus::Base, Object]
|
|
31
|
+
def bus=(bus_or_symbol)
|
|
32
|
+
@mutex.synchronize { @bus = _resolve_bus(bus_or_symbol) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the active bus adapter. Defaults to a Memory bus.
|
|
36
|
+
#
|
|
37
|
+
# Falls back to Easyop.config.event_bus if set, then :memory.
|
|
38
|
+
#
|
|
39
|
+
# @return [Bus::Base]
|
|
40
|
+
def bus
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@bus ||= _resolve_bus(
|
|
43
|
+
defined?(Easyop.config) && Easyop.config.respond_to?(:event_bus) && Easyop.config.event_bus ||
|
|
44
|
+
:memory
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Register a handler operation as a subscriber for +pattern+.
|
|
50
|
+
#
|
|
51
|
+
# Called automatically by the EventHandlers plugin when `on` is declared.
|
|
52
|
+
#
|
|
53
|
+
# @param pattern [String, Regexp] event name or glob
|
|
54
|
+
# @param handler_class [Class] operation class to invoke
|
|
55
|
+
# @param async [Boolean] use call_async (requires Async plugin)
|
|
56
|
+
# @param options [Hash] e.g. queue: "low"
|
|
57
|
+
def register_handler(pattern:, handler_class:, async: false, **options)
|
|
58
|
+
entry = { pattern: pattern, handler_class: handler_class,
|
|
59
|
+
async: async, options: options }
|
|
60
|
+
|
|
61
|
+
@mutex.synchronize { _subscriptions << entry }
|
|
62
|
+
|
|
63
|
+
bus.subscribe(pattern) { |event| _dispatch(event, entry) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns a copy of all registered handler entries (for introspection).
|
|
67
|
+
#
|
|
68
|
+
# @return [Array<Hash>]
|
|
69
|
+
def subscriptions
|
|
70
|
+
@mutex.synchronize { _subscriptions.dup }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Reset the registry: drop all subscriptions and replace the bus with a
|
|
74
|
+
# fresh Memory instance. Intended for use in tests (called in before/after hooks).
|
|
75
|
+
def reset!
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@bus = nil
|
|
78
|
+
@subscriptions = nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def _subscriptions
|
|
85
|
+
@subscriptions ||= []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @api private — exposed for specs
|
|
89
|
+
def _dispatch(event, entry)
|
|
90
|
+
handler_class = entry[:handler_class]
|
|
91
|
+
|
|
92
|
+
if entry[:async] && handler_class.respond_to?(:call_async)
|
|
93
|
+
# Serialize Event to a plain hash for ActiveJob compatibility.
|
|
94
|
+
attrs = event.payload.merge(event_data: event.to_h)
|
|
95
|
+
queue = entry[:options][:queue]
|
|
96
|
+
handler_class.call_async(attrs, **(queue ? { queue: queue } : {}))
|
|
97
|
+
else
|
|
98
|
+
# Sync: pass Event object directly — ctx.event is the live Event instance.
|
|
99
|
+
handler_class.call(event: event, **event.payload)
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError
|
|
102
|
+
# Handler failures must not propagate back to the publisher.
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def _resolve_bus(bus_or_symbol)
|
|
106
|
+
case bus_or_symbol
|
|
107
|
+
when :memory
|
|
108
|
+
Bus::Memory.new
|
|
109
|
+
when :active_support
|
|
110
|
+
Bus::ActiveSupportNotifications.new
|
|
111
|
+
when Bus::Base
|
|
112
|
+
bus_or_symbol
|
|
113
|
+
when nil
|
|
114
|
+
Bus::Memory.new
|
|
115
|
+
else
|
|
116
|
+
if bus_or_symbol.respond_to?(:publish) && bus_or_symbol.respond_to?(:subscribe)
|
|
117
|
+
Bus::Custom.new(bus_or_symbol)
|
|
118
|
+
else
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Unknown bus: #{bus_or_symbol.inspect}. " \
|
|
121
|
+
"Use :memory, :active_support, or a bus adapter instance."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/easyop/flow.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
1
3
|
module Easyop
|
|
2
4
|
# Compose a sequence of operations that share a single ctx.
|
|
3
5
|
#
|
|
@@ -11,6 +13,27 @@ module Easyop
|
|
|
11
13
|
# flow ValidateCart, ChargeCard, CreateOrder, NotifyUser
|
|
12
14
|
# end
|
|
13
15
|
#
|
|
16
|
+
# ## Recording plugin integration (flow tracing)
|
|
17
|
+
#
|
|
18
|
+
# When steps have the Recording plugin installed, `CallBehavior#call` forwards
|
|
19
|
+
# the parent ctx keys so every step log entry shows the flow as its parent:
|
|
20
|
+
#
|
|
21
|
+
# # Bare flow — flow itself is not recorded but steps see it as parent:
|
|
22
|
+
# class ProcessOrder
|
|
23
|
+
# include Easyop::Flow
|
|
24
|
+
# flow ValidateCart, ChargeCard
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# For full tree reconstruction (flow appears in operation_logs as the root
|
|
28
|
+
# entry) inherit from your recorded base class and opt out of the transaction
|
|
29
|
+
# so step-level transactions are not shadowed by an outer one:
|
|
30
|
+
#
|
|
31
|
+
# class ProcessOrder < ApplicationOperation
|
|
32
|
+
# include Easyop::Flow
|
|
33
|
+
# transactional false # EasyOp handles rollback; steps own their transactions
|
|
34
|
+
# flow ValidateCart, ChargeCard
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
14
37
|
# result = ProcessOrder.call(user: user, cart: cart)
|
|
15
38
|
# result.on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
16
39
|
# result.on_failure { |ctx| flash[:alert] = ctx.error }
|
|
@@ -31,6 +54,25 @@ module Easyop
|
|
|
31
54
|
# otherwise place Operation earlier in the ancestor chain than Flow itself).
|
|
32
55
|
module CallBehavior
|
|
33
56
|
def call
|
|
57
|
+
# ── Flow-tracing forwarding for the Recording plugin ──────────────────
|
|
58
|
+
# When Recording is NOT installed on this flow class (i.e. the flow does
|
|
59
|
+
# not inherit from a base operation that has Recording), set the
|
|
60
|
+
# __recording_parent_* ctx keys manually so every step operation knows
|
|
61
|
+
# this flow is its parent. When Recording IS installed on the flow (its
|
|
62
|
+
# RunWrapper runs before `call` is reached), it has already set up the
|
|
63
|
+
# parent context correctly — we detect that via _recording_enabled? and
|
|
64
|
+
# skip to avoid a conflict. If Recording is not used at all, these ctx
|
|
65
|
+
# keys are unused and ignored.
|
|
66
|
+
_flow_tracing = self.class.name &&
|
|
67
|
+
!self.class.respond_to?(:_recording_enabled?)
|
|
68
|
+
if _flow_tracing
|
|
69
|
+
ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
|
|
70
|
+
_prev_parent_name = ctx[:__recording_parent_operation_name]
|
|
71
|
+
_prev_parent_id = ctx[:__recording_parent_reference_id]
|
|
72
|
+
ctx[:__recording_parent_operation_name] = self.class.name
|
|
73
|
+
ctx[:__recording_parent_reference_id] = SecureRandom.uuid
|
|
74
|
+
end
|
|
75
|
+
|
|
34
76
|
pending_guard = nil
|
|
35
77
|
|
|
36
78
|
self.class._flow_steps.each do |step|
|
|
@@ -56,6 +98,12 @@ module Easyop
|
|
|
56
98
|
rescue Ctx::Failure
|
|
57
99
|
ctx.rollback!
|
|
58
100
|
raise
|
|
101
|
+
ensure
|
|
102
|
+
# Restore parent context so any caller above this flow sees the right parent.
|
|
103
|
+
if _flow_tracing
|
|
104
|
+
ctx[:__recording_parent_operation_name] = _prev_parent_name
|
|
105
|
+
ctx[:__recording_parent_reference_id] = _prev_parent_id
|
|
106
|
+
end
|
|
59
107
|
end
|
|
60
108
|
end
|
|
61
109
|
|