listenable 0.3.0 → 0.3.1

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: a40496b36e34ee9be039eafd3ca5ce235d7fff2c2ccd93dfb1041bd75fa5b87f
4
- data.tar.gz: 89505e376d1f337dd5e9d7172383a07501846613fcf556fa94682383b2a42d85
3
+ metadata.gz: a8ebfdc90196162ce2a340f960b6c7f0f1de287c4806cf230bded917eee5bd3f
4
+ data.tar.gz: 8435bcb681df67ae94a66225e5f95765145cb81dfbed4b4f02362682fceadff4
5
5
  SHA512:
6
- metadata.gz: 7b2a55fee58e33e6f9f3fe1354d67a86668c93294e9e94274fba575ebe415805952f5e53179bf0fd041d00ab361e4c01b07390df3ac4a1f1eebd82eddf50505b
7
- data.tar.gz: ef07a946e9bfc66eb038d09b765977cf44e766e2209295d2427bb7f3ad24e35d9827a3c473905d2baecb10f28814e344600ba857650e453c976622604f65074b
6
+ metadata.gz: 80f93245b2d340672b0c9178f637f3a127230a4348e08b3099f27d2ff43cdd9b8baa77cdf6bf8492ef446c5e55666c3f2b4a16e1b073b1ac33bdd7439f5f1491
7
+ data.tar.gz: 69681153c86034c8a4c8563478cf6c580b97409566bc50d87537c477106d040fa6837e29b578a0b39ae37f23dbf90a9f4fdab23d7a743a4153afb65f59ee46e4
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Listenable
4
+ extend ActiveSupport::Concern
5
+
4
6
  CALLBACK_MAP = {
5
7
  'created' => :after_create,
6
8
  'updated' => :after_update,
7
9
  'deleted' => :after_destroy
8
10
  }.freeze
9
11
 
10
- def self.included(base)
11
- base.extend(ClassMethods)
12
+ included do
13
+ # Register this class when Listenable is included
14
+ Listenable.register_listener(self)
12
15
  end
13
16
 
14
- module ClassMethods
17
+ class_methods do
15
18
  def listen(*hooks, async: false)
16
19
  @pending_hooks ||= []
17
20
 
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Listenable
4
4
  class Railtie < Rails::Railtie
5
+ AFTER_COMMIT_MAP = {
6
+ 'created' => :create,
7
+ 'updated' => :update,
8
+ 'destroyed' => :destroy
9
+ }.freeze
10
+
5
11
  # Cleanup on Rails reload to prevent memory leaks
6
12
  config.to_prepare do
7
13
  Listenable.cleanup!
@@ -17,57 +23,72 @@ module Listenable
17
23
  initializer 'listenable.load' do
18
24
  Rails.application.config.to_prepare do
19
25
  # Load all listeners (recursive, supports namespaced)
20
- listener_files = Dir[Rails.root.join('app/listeners/**/*.rb')]
21
- listener_files.each { |f| require_dependency f }
26
+ Dir[Rails.root.join('app/listeners/**/*.rb')].each do |file|
27
+ require_dependency file
28
+ end
22
29
 
23
- # Find all listener classes
24
- listener_classes = ObjectSpace.each_object(Class).select { |klass| klass < Listenable }
25
- listener_classes.each do |listener_class|
30
+ Listenable.listener_classes.each do |listener_class|
26
31
  model_class_name = listener_class.name.sub('Listener', '')
27
32
  model_class = model_class_name.safe_constantize
28
33
  next unless model_class
29
34
 
35
+ injected_events =
36
+ model_class.instance_variable_get(:@_listenable_injected_events) || []
37
+
30
38
  listener_class.pending_hooks.each do |hook_info|
31
39
  hook = hook_info[:name]
32
40
  async = hook_info[:async]
33
41
  action = hook.sub('on_', '')
34
- callback = Listenable::CALLBACK_MAP[action] or next
35
42
  method = "on_#{action}"
36
43
  event = "#{model_class_name.underscore}.#{action}"
37
44
 
38
- # unsubscribe old subscribers
39
- ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
40
- ActiveSupport::Notifications.unsubscribe(subscriber)
41
- end
45
+ next unless listener_class.respond_to?(method)
42
46
 
43
- injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
47
+ # Inject callback once per model
44
48
  unless injected_events.include?(event)
45
- model_class.send(callback) do
49
+ commit_action = AFTER_COMMIT_MAP[action]
50
+ next unless commit_action
51
+
52
+ model_class.after_commit(on: commit_action) do
46
53
  next unless Listenable.enabled
47
54
 
48
- ActiveSupport::Notifications.instrument(event, record: self)
55
+ ActiveSupport::Notifications.instrument(
56
+ event,
57
+ record_class: self.class,
58
+ record_id: id
59
+ )
49
60
  end
61
+
50
62
  injected_events << event
51
- model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
63
+ model_class.instance_variable_set(
64
+ :@_listenable_injected_events,
65
+ injected_events
66
+ )
52
67
  end
53
68
 
54
- next unless listener_class.respond_to?(method)
55
-
56
- # Subscribe and track subscriber for cleanup
69
+ # Subscribe (only once per reload)
57
70
  subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
58
71
  next unless Listenable.enabled
59
72
 
60
73
  _name, _start, _finish, _id, payload = args
61
- record = payload[:record]
62
74
 
63
75
  if async
64
- Railtie.handle_async_listener(listener_class, method, record)
76
+ Railtie.handle_async_listener(
77
+ listener_class,
78
+ method,
79
+ payload[:record_class],
80
+ payload[:record_id]
81
+ )
65
82
  else
66
- Railtie.handle_sync_listener(listener_class, method, record)
83
+ Railtie.handle_sync_listener(
84
+ listener_class,
85
+ method,
86
+ payload[:record_class],
87
+ payload[:record_id]
88
+ )
67
89
  end
68
90
  end
69
91
 
70
- # Track subscriber for cleanup on reload
71
92
  Listenable.subscribers << subscriber
72
93
  end
73
94
  end
@@ -75,52 +96,46 @@ module Listenable
75
96
  end
76
97
 
77
98
  class << self
78
- # Handle async listener with proper error handling and connection management
79
- def handle_async_listener(listener_class, method, record)
80
- # Extract minimal data to pass to thread
81
- record_id = record.id
82
- record_class = record.class
83
-
84
- # Use bounded thread pool to prevent spawning unlimited threads
99
+ def handle_async_listener(listener_class, method, record_class, record_id)
85
100
  Concurrent::Promises.future_on(Listenable.async_executor) do
86
- # Wrap in connection pool management to prevent connection exhaustion
87
101
  ActiveRecord::Base.connection_pool.with_connection do
88
102
  execute_listener(listener_class, method, record_class, record_id)
89
- rescue StandardError => e
90
- log_error(listener_class, method, e)
91
103
  end
92
- end.rescue do |e|
104
+ rescue ActiveRecord::ConnectionTimeoutError => e
93
105
  Rails.logger&.error(
94
- "[Listenable] Promise failed for #{listener_class}##{method}: #{e.message}"
106
+ "[Listenable] DB pool exhausted for #{listener_class}##{method}: #{e.message}"
95
107
  )
108
+ rescue StandardError => e
109
+ log_error(listener_class, method, e)
96
110
  end
97
111
  end
98
112
 
99
- # Handle sync listener with proper error handling
100
- def handle_sync_listener(listener_class, method, record)
101
- listener_class.public_send(method, record)
113
+ def handle_sync_listener(listener_class, method, record_class, record_id)
114
+ execute_listener(listener_class, method, record_class, record_id)
102
115
  rescue StandardError => e
103
116
  log_error(listener_class, method, e)
104
- raise # Re-raise for sync listeners to maintain transaction integrity
117
+ raise
105
118
  end
106
119
 
107
120
  private
108
121
 
109
122
  def execute_listener(listener_class, method, record_class, record_id)
110
- reloaded_record = record_class.find_by(id: record_id)
123
+ record = record_class.find_by(id: record_id)
111
124
 
112
- if reloaded_record
113
- listener_class.public_send(method, reloaded_record)
114
- else
125
+ unless record
115
126
  Rails.logger&.warn(
116
- "[Listenable] Record #{record_class}##{record_id} not found for #{listener_class}##{method}"
127
+ "[Listenable] #{record_class}##{record_id} not found for #{listener_class}##{method}"
117
128
  )
129
+ return
118
130
  end
131
+
132
+ listener_class.public_send(method, record)
119
133
  end
120
134
 
121
135
  def log_error(listener_class, method, error)
122
136
  Rails.logger&.error(
123
- "[Listenable] #{listener_class}##{method} failed: #{error.message}\n#{error.backtrace.first(5).join("\n")}"
137
+ "[Listenable] #{listener_class}##{method} failed: " \
138
+ "#{error.message}\n#{error.backtrace.first(5).join("\n")}"
124
139
  )
125
140
  end
126
141
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Listenable
4
- VERSION = '0.3.0'
4
+ VERSION = '0.3.1'
5
5
  end
data/lib/listenable.rb CHANGED
@@ -4,6 +4,7 @@ require 'active_support'
4
4
  require 'active_support/concern'
5
5
  require 'active_support/notifications'
6
6
  require 'concurrent'
7
+ require 'set'
7
8
 
8
9
  require_relative 'listenable/version'
9
10
  require_relative 'listenable/concern'
@@ -12,24 +13,45 @@ require_relative 'listenable/railtie' if defined?(Rails)
12
13
  module Listenable
13
14
  mattr_accessor :enabled, default: true
14
15
 
16
+ # Connection pool safety configuration
17
+ mattr_accessor :connection_checkout_timeout, default: 5 # seconds
18
+ mattr_accessor :max_thread_pool_ratio, default: 0.25 # 25% of connection pool
19
+ mattr_accessor :max_thread_pool_size, default: 3 # absolute max threads
20
+
15
21
  class Error < StandardError; end
22
+ class ConnectionPoolExhausted < Error; end
16
23
 
17
24
  class << self
18
25
  attr_writer :async_executor
19
26
 
27
+ # Registry of all listener classes (better than ObjectSpace scan)
28
+ def listener_classes
29
+ @listener_classes ||= Set.new
30
+ end
31
+
32
+ # Register a listener class when Listenable is included
33
+ def register_listener(klass)
34
+ listener_classes.add(klass) if klass.name && !klass.name.empty?
35
+ end
36
+
37
+ # Clear registry on reload
38
+ def clear_listeners!
39
+ @listener_classes = Set.new
40
+ end
41
+
20
42
  # Track active subscribers to prevent memory leaks on reload
21
43
  def subscribers
22
44
  @subscribers ||= []
23
45
  end
24
46
 
25
47
  # Calculate a safe thread pool size based on connection pool
26
- # Very conservative: use only 1/4 of pool (min 1, max 3)
48
+ # Very conservative: use configurable ratio of pool (default 25%)
27
49
  def default_thread_pool_size
28
50
  return 2 unless defined?(ActiveRecord::Base)
29
51
 
30
52
  pool_size = ActiveRecord::Base.connection_pool.size
31
- # Use 25% of pool, but at least 1 thread, max 3 threads
32
- [[pool_size / 4, 1].max, 3].min
53
+ # Use configured ratio of pool, but at least 1 thread, max configured limit
54
+ [[pool_size * max_thread_pool_ratio, 1].max, max_thread_pool_size].min.to_i
33
55
  end
34
56
 
35
57
  # Thread pool executor for async listeners
@@ -55,6 +77,9 @@ module Listenable
55
77
  end
56
78
  @subscribers = []
57
79
 
80
+ # Clear listener registry for fresh reload
81
+ clear_listeners!
82
+
58
83
  # Shutdown thread pool gracefully
59
84
  shutdown_async_executor!
60
85
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: listenable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Den Meralpis
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ostruct
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rails
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -118,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
118
132
  - !ruby/object:Gem::Version
119
133
  version: '0'
120
134
  requirements: []
121
- rubygems_version: 3.6.9
135
+ rubygems_version: 4.0.3
122
136
  specification_version: 4
123
137
  summary: A Rails DSL for model event listeners using ActiveSupport::Notifications.
124
138
  test_files: []