easyop 0.1.1 → 0.1.3

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,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
@@ -27,6 +27,18 @@ module Easyop
27
27
  end
28
28
 
29
29
  module ClassMethods
30
+ # Declares a non-default queue for this operation class and its subclasses.
31
+ #
32
+ # @example
33
+ # class Weather::BaseOperation < ApplicationOperation
34
+ # queue :weather
35
+ # end
36
+ #
37
+ # @param name [String, Symbol] queue name
38
+ def queue(name)
39
+ @_async_default_queue = name.to_s
40
+ end
41
+
30
42
  # Enqueue the operation as a background job.
31
43
  #
32
44
  # MyOp.call_async(email: "x@y.com")
@@ -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