koine-event_manager 3.0.0 → 3.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83204e64656ac138a1079ca46b9380d9e925ab10a4d0e4b3af45a25d5253d5fe
4
- data.tar.gz: 7db31b4a992772eefce2cda6a15588e29c199d7ba6bf2999e77598129f02a366
3
+ metadata.gz: 48dfe1c350d523d6dc0b09cec81848a225fa41cf02edd07fbe3c902f4e9ba826
4
+ data.tar.gz: c5d594bac4dc7e8ed338d485e5e5a2fcd4558f35533cd496d4497783644a543f
5
5
  SHA512:
6
- metadata.gz: d29000bd69044fd15aae4935ce9d7dbc937b0e569aee4ade80393cbe32e054898b369fbd2cbe755a21bd2c64671e4e867e33d6887205e3ae09ad3caabaf5ed1a
7
- data.tar.gz: 8326965bdc59e68b12c858f13c8196a813f33cb0596c800a236f0c0ebde88b627d9ce6c94341c0422aa840f059c162b742678d03ea18ee2c2b71af31ed20a860
6
+ metadata.gz: a465c356d1944acb9cb2c2aea7f4f269877a253dce7fce66798cb9cd6560c80c196c0151f7fbba0c746e6816bbca3038ac31f6e0a1bc272d42696f52b6af7755
7
+ data.tar.gz: 87700e2f619c41c382cace295d90b3d76afba14c3af5174769117b78196796199ed6921faa4b6e97ece79bb1889589b6b48089d9dc811d71d82081347230fcd1
data/README.md CHANGED
@@ -242,12 +242,69 @@ class SessionsController < ApplicationController
242
242
  end
243
243
  ```
244
244
 
245
+ ### Error Isolation
246
+
247
+ By default, an exception raised by a listener or subscriber propagates and aborts
248
+ the dispatch. Pass an `on_error` callback to isolate failures so the remaining
249
+ listeners still run:
250
+
251
+ ```ruby
252
+ event_manager = Koine::EventManager::EventManager.new(
253
+ on_error: ->(error, event:, listener:) { Bugsnag.notify(error) }
254
+ )
255
+ ```
256
+
257
+ The callback is invoked as `on_error.call(error, event:, listener:)`, so it **must
258
+ accept the `event:` and `listener:` keyword arguments** (the failing event and the
259
+ listener/subscriber that raised). Use them to attribute the failure, or ignore them
260
+ with a splat:
261
+
262
+ ```ruby
263
+ on_error: ->(error, **) { Bugsnag.notify(error) }
264
+ ```
265
+
266
+ Without `on_error` the behavior is unchanged (errors re-raise).
267
+
268
+ ### Post-commit Dispatch (Rails)
269
+
270
+ `require "koine/event_manager/rails"` adds `TransactionalEventManager`, a drop-in
271
+ subclass that defers dispatch until the current database transaction commits (or
272
+ runs immediately when none is open), via `ActiveRecord.after_all_transactions_commit`.
273
+ This keeps listeners from reacting to changes that a rollback would undo.
274
+
275
+ ```ruby
276
+ require "koine/event_manager/rails"
277
+
278
+ bus = Koine::EventManager::TransactionalEventManager.new(
279
+ on_error: ->(error, **) { Bugsnag.notify(error) }
280
+ )
281
+ ```
282
+
283
+ Requires ActiveRecord >= 7.2; the core library remains dependency-free, so only load
284
+ this file from within a Rails/ActiveRecord process.
285
+
286
+ Subscribers (and block listeners) are deferred by default. A reaction that must run
287
+ **inside** the transaction — e.g. to write a related record atomically, or to raise
288
+ and roll the whole operation back — can opt out with `after_commit: false`:
289
+
290
+ ```ruby
291
+ bus.subscribe(AuditWriter.new, to: ProposalCreated) # post-commit, isolated
292
+ bus.subscribe(InventoryReserver.new, to: ProposalCreated, after_commit: false) # in-transaction
293
+ bus.listen_to(ProposalCreated, after_commit: false) { |e| ... } # in-transaction block
294
+ ```
295
+
296
+ Synchronous (`after_commit: false`) listeners run inline and their errors **propagate**
297
+ (bypassing `on_error`), so a failed integrity reaction aborts the surrounding
298
+ transaction. Deferred listeners stay isolated by `on_error`.
299
+
245
300
  ## API Reference
246
301
 
247
302
  ### EventManager
248
303
 
304
+ - `EventManager.new(on_error: nil)` - Optionally isolate listener errors (see Error Isolation)
249
305
  - `listen_to(event_class, &block)` - Register a block to handle events
250
306
  - `trigger(event)` - Dispatch an event to all listeners and subscribers
307
+ - `publish(event)` - Alias for `trigger` (pub/sub vocabulary)
251
308
  - `subscribe(subscriber, to: event_type)` - Add a subscriber for an event type
252
309
  - `unsubscribe(subscriber, from: event_type)` - Remove a subscriber
253
310
  - `attach_listener(listener)` - Attach an EventListener instance
@@ -3,9 +3,10 @@
3
3
  module Koine
4
4
  module EventManager
5
5
  class EventListener
6
- def initialize
6
+ def initialize(on_error: nil)
7
7
  @listeners = {}
8
8
  @subscribers = {}
9
+ @on_error = on_error
9
10
  end
10
11
 
11
12
  def listen_to(event_type, &block)
@@ -30,13 +31,13 @@ module Koine
30
31
 
31
32
  def trigger(event_object)
32
33
  listeners_for(event_object.class).each do |block|
33
- block.call(event_object)
34
+ dispatch(event_object, block) { block.call(event_object) }
34
35
  end
35
36
 
36
37
  subscribers.each do |subscriber, events|
37
38
  events.each do |event|
38
39
  if event_object.class.ancestors.map(&:to_s).include?(event.to_s)
39
- subscriber.publish(event_object)
40
+ dispatch(event_object, subscriber) { subscriber.publish(event_object) }
40
41
  end
41
42
  end
42
43
  end
@@ -50,6 +51,14 @@ module Koine
50
51
 
51
52
  private
52
53
 
54
+ def dispatch(event, listener)
55
+ yield
56
+ rescue StandardError => e
57
+ raise if @on_error.nil?
58
+
59
+ @on_error.call(e, event: event, listener: listener)
60
+ end
61
+
53
62
  def add_listener(event_type, &block)
54
63
  listeners[event_type.to_s] ||= []
55
64
  listeners[event_type.to_s] << block
@@ -3,8 +3,13 @@
3
3
  module Koine
4
4
  module EventManager
5
5
  class EventManager
6
- def initialize
7
- @internal_listener = EventListener.new
6
+ def initialize(on_error: nil)
7
+ @on_error = on_error
8
+ @internal_listener = EventListener.new(on_error: on_error)
9
+ end
10
+
11
+ def publish(event)
12
+ trigger(event)
8
13
  end
9
14
 
10
15
  def listen_to(event, &block)
@@ -31,13 +36,23 @@ module Koine
31
36
  @internal_listener.trigger(event)
32
37
 
33
38
  listeners.each do |listener|
34
- listener.trigger(event)
39
+ dispatch(event, listener) { listener.trigger(event) }
35
40
  end
36
41
  end
37
42
 
38
43
  def listeners
39
44
  @listeners ||= []
40
45
  end
46
+
47
+ private
48
+
49
+ def dispatch(event, listener)
50
+ yield
51
+ rescue StandardError => e
52
+ raise if @on_error.nil?
53
+
54
+ @on_error.call(e, event: event, listener: listener)
55
+ end
41
56
  end
42
57
  end
43
58
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'koine/event_manager'
4
+
5
+ module Koine
6
+ module EventManager
7
+ # Dispatches listeners after the current DB transaction commits (or runs
8
+ # immediately when none is open), so they never react to changes a rollback
9
+ # would undo. Requires ActiveRecord >= 7.2.
10
+ #
11
+ # Individual subscribers/listeners may opt into synchronous, in-transaction
12
+ # dispatch with `after_commit: false`. Those run inline and their errors
13
+ # propagate (bypassing `on_error` isolation), so an integrity reaction can
14
+ # abort the surrounding transaction. Post-commit listeners stay isolated.
15
+ class TransactionalEventManager < EventManager
16
+ alias deferred_dispatch trigger
17
+
18
+ def initialize(on_error: nil)
19
+ super
20
+ # In-transaction listeners: no on_error isolation, so a failure propagates
21
+ # and can roll the surrounding transaction back.
22
+ @synchronous = EventListener.new
23
+ end
24
+
25
+ def subscribe(subscriber, to:, after_commit: true)
26
+ return @synchronous.subscribe(subscriber, to: to) unless after_commit
27
+
28
+ super(subscriber, to: to)
29
+ end
30
+
31
+ def listen_to(event, after_commit: true, &block)
32
+ return @synchronous.listen_to(event, &block) unless after_commit
33
+
34
+ super(event, &block)
35
+ end
36
+
37
+ def unsubscribe(subscriber, from:)
38
+ @synchronous.unsubscribe(subscriber, from: from)
39
+ super
40
+ end
41
+
42
+ def trigger(event)
43
+ @synchronous.trigger(event)
44
+
45
+ unless active_record_available?
46
+ raise LoadError, 'TransactionalEventManager requires ActiveRecord >= 7.2 ' \
47
+ '(after_all_transactions_commit). Load Rails before using it.'
48
+ end
49
+
50
+ ::ActiveRecord.after_all_transactions_commit do
51
+ deferred_dispatch(event)
52
+ end
53
+ end
54
+
55
+ private :deferred_dispatch
56
+
57
+ private
58
+
59
+ def active_record_available?
60
+ defined?(::ActiveRecord) &&
61
+ ::ActiveRecord.respond_to?(:after_all_transactions_commit)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Koine
4
4
  module EventManager
5
- VERSION = '3.0.0'
5
+ VERSION = '3.1.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: koine-event_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Jacobus
@@ -32,6 +32,7 @@ files:
32
32
  - lib/koine/event_manager.rb
33
33
  - lib/koine/event_manager/event_listener.rb
34
34
  - lib/koine/event_manager/event_manager.rb
35
+ - lib/koine/event_manager/rails.rb
35
36
  - lib/koine/event_manager/version.rb
36
37
  homepage: https://github.com/mjacobus/koine-event-manager
37
38
  licenses: