listenable 0.2.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: 39c0faefb1a4b56eb6e359bc950a75bad47be727181b3fa7e751722c737a9fb3
4
- data.tar.gz: 271cb3a4ccf5b357943967e9c503e8c6344be7e10e0439b7f6b0838b205fa228
3
+ metadata.gz: a8ebfdc90196162ce2a340f960b6c7f0f1de287c4806cf230bded917eee5bd3f
4
+ data.tar.gz: 8435bcb681df67ae94a66225e5f95765145cb81dfbed4b4f02362682fceadff4
5
5
  SHA512:
6
- metadata.gz: 4012acf44c48b3ebbc64060fe4488f81efd61eac703f476d20447ca6270003df1ddb5b27a997c43a59474827a2c2ab0bb0e92b18226379a7e19a3325540d8830
7
- data.tar.gz: a8046f1f29c00b3b2db7fa90603c5b6acf69ccf868fdd84ce7064f1964a6e112e44cd9749a67c5bd538064b72bc4112e203cfd47674918945a97d6e792c57c9e
6
+ metadata.gz: 80f93245b2d340672b0c9178f637f3a127230a4348e08b3099f27d2ff43cdd9b8baa77cdf6bf8492ef446c5e55666c3f2b4a16e1b073b1ac33bdd7439f5f1491
7
+ data.tar.gz: 69681153c86034c8a4c8563478cf6c580b97409566bc50d87537c477106d040fa6837e29b578a0b39ae37f23dbf90a9f4fdab23d7a743a4153afb65f59ee46e4
data/README.md CHANGED
@@ -136,65 +136,6 @@ class UserListener
136
136
  end
137
137
  ```
138
138
 
139
- ## Supported hooks
140
- | Listener hook | Model callback | Execution Mode |
141
- |-----------------------|-----------------------|----------------|
142
- | `on_created` | `after_create` | Synchronous (default) or Asynchronous with `async: true` |
143
- | `on_updated` | `after_update` | Synchronous (default) or Asynchronous with `async: true` |
144
- | `on_deleted` | `after_destroy` | Synchronous (default) or Asynchronous with `async: true` |
145
-
146
- All hooks support both synchronous and asynchronous execution modes via the `async: true` option.
147
-
148
- ## Runtime Toggle
149
- By default, listeners are always active in development and production.
150
-
151
- You can enable/disable them dynamically at runtime using:
152
-
153
- ```ruby
154
- Listenable.enabled = false # disable all listeners
155
- Listenable.enabled = true # re-enable listeners
156
- ```
157
-
158
- This does not require restarting your Rails server or test suite.
159
-
160
- ## RSpec/Test Integration
161
- You usually don’t want listeners firing in tests (e.g. sending jobs or emails).
162
-
163
- Disable them globally in your test suite:
164
-
165
- ```ruby
166
- # spec/rails_helper.rb
167
- RSpec.configure do |config|
168
- config.before(:suite) do
169
- Listenable.enabled = false
170
- end
171
-
172
- # Enable listeners selectively
173
- config.around(:each, listenable: true) do |example|
174
- prev = Listenable.enabled
175
- Listenable.enabled = true
176
- example.run
177
- Listenable.enabled = prev
178
- end
179
- end
180
- ```
181
-
182
- Now:
183
-
184
- ```ruby
185
- RSpec.describe User do
186
- it 'does not fire listeners by default' do
187
- expect(UserListener).not_to receive(:on_created)
188
- User.create!(name: 'Pedro')
189
- end
190
-
191
- it 'fires synchronous listeners when enabled', listenable: true do
192
- expect(UserListener).to receive(:on_created)
193
- User.create!(name: 'Pedro')
194
- end
195
- end
196
- ```
197
-
198
139
  ## ⚠️ Important: Execution Modes and Performance
199
140
 
200
141
  ### Synchronous Listeners (Default Behavior)
@@ -253,17 +194,126 @@ end
253
194
 
254
195
  **Note**: Asynchronous listeners use `Concurrent::Promises` for thread-safe execution. Errors in async listeners are logged but won't affect the main request flow.
255
196
 
256
- ### Choosing the Right Mode
197
+ #### Database Connection Management
198
+
199
+ Asynchronous listeners are automatically wrapped in ActiveRecord's connection pool management (`connection_pool.with_connection`) to prevent connection pool exhaustion. The record is reloaded in the async thread to ensure thread-safe database access:
200
+
201
+ ```ruby
202
+ class UserListener
203
+ include Listenable
204
+
205
+ listen :on_updated, async: true
206
+
207
+ def self.on_updated(user)
208
+ # The record is automatically reloaded in this thread
209
+ # Safe to access attributes and associations
210
+ user.name # ✅ Safe
211
+ user.orders.count # ✅ Safe - proper connection management
212
+
213
+ # Heavy operations that query the database
214
+ UserAnalyticsService.new(user).calculate_metrics # ✅ Safe
215
+ end
216
+ end
217
+ ```
218
+
219
+ **Important**: The record passed to async listeners is reloaded from the database in the async thread. If the record is deleted before the async listener executes, the listener will receive `nil` and a warning will be logged.
220
+
221
+ #### Thread Pool & Bulk Operations
257
222
 
258
- - **Use synchronous listeners** for:
259
- - Critical operations that must complete before the request finishes
260
- - Simple, fast operations (logging, simple updates)
261
- - Operations that need to participate in the same database transaction
223
+ Async listeners use a **bounded thread pool** that automatically scales based on your database connection pool size:
262
224
 
263
- - **Use asynchronous listeners** for:
264
- - Heavy computations or external API calls
265
- - Non-critical operations that can fail independently
266
- - Operations that don't need to complete before the response is sent
225
+ ```ruby
226
+ # Automatically configured based on your connection pool
227
+ Listenable.async_executor # Auto-scales intelligently
228
+ ```
229
+
230
+ **Auto-Scaling Formula** (Very Conservative):
231
+ - `max_threads`: **25% of connection pool size** (minimum 1, maximum 3)
232
+ - `max_queue`: 10,000 tasks can be queued
233
+ - `fallback_policy`: Falls back to synchronous execution if queue is full
234
+
235
+ **Examples**:
236
+ - Connection pool of 5 → **1 thread** for async listeners
237
+ - Connection pool of 10 → **2 threads** for async listeners
238
+ - Connection pool of 20 → **3 threads** for async listeners (capped at 3)
239
+
240
+ **Why 25%?** Your main application needs most connections for handling requests. By using only 25% of the pool for async work, we ensure:
241
+ - Bulk operations never exhaust the connection pool
242
+ - Your main app always has connections available
243
+ - Async work is processed reliably through queuing
244
+
245
+ This ensures that bulk operations (updating thousands of records) don't exhaust your database connection pool:
246
+
247
+ ```ruby
248
+ # This works safely even with thousands of records
249
+ CSV.foreach('users.csv') do |row|
250
+ user = User.find(row['id'])
251
+ user.update!(name: row['name']) # Async listeners execute without exhausting pool
252
+ end
253
+ ```
254
+
255
+ **Manual Override (if needed)**:
256
+
257
+ If you want to override the auto-scaling, you can reset the executor with a custom size:
258
+
259
+ ```ruby
260
+ # config/initializers/listenable.rb
261
+
262
+ # Increase for larger connection pools
263
+ Listenable.reset_async_executor! # Reset existing executor
264
+ # Then create a custom executor if needed
265
+ # (Note: Auto-scaling is recommended for most cases)
266
+ ```
267
+
268
+ **⚠️ Warning**: The auto-scaling is conservative by design. Only override if you have a very large connection pool (20+) and understand the implications.
269
+ By default, listeners are always active in development and production.
270
+
271
+ You can enable/disable them dynamically at runtime using:
272
+
273
+ ```ruby
274
+ Listenable.enabled = false # disable all listeners
275
+ Listenable.enabled = true # re-enable listeners
276
+ ```
277
+
278
+ This does not require restarting your Rails server or test suite.
279
+
280
+ ## RSpec/Test Integration
281
+ You usually don’t want listeners firing in tests (e.g. sending jobs or emails).
282
+
283
+ Disable them globally in your test suite:
284
+
285
+ ```ruby
286
+ # spec/rails_helper.rb
287
+ RSpec.configure do |config|
288
+ config.before(:suite) do
289
+ Listenable.enabled = false
290
+ end
291
+
292
+ # Enable listeners selectively
293
+ config.around(:each, listenable: true) do |example|
294
+ prev = Listenable.enabled
295
+ Listenable.enabled = true
296
+ example.run
297
+ Listenable.enabled = prev
298
+ end
299
+ end
300
+ ```
301
+
302
+ Now:
303
+
304
+ ```ruby
305
+ RSpec.describe User do
306
+ it 'does not fire listeners by default' do
307
+ expect(UserListener).not_to receive(:on_created)
308
+ User.create!(name: 'Pedro')
309
+ end
310
+
311
+ it 'fires synchronous listeners when enabled', listenable: true do
312
+ expect(UserListener).to receive(:on_created)
313
+ User.create!(name: 'Pedro')
314
+ end
315
+ end
316
+ ```
267
317
 
268
318
  ## Development
269
319
 
@@ -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,62 +2,142 @@
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
+
11
+ # Cleanup on Rails reload to prevent memory leaks
12
+ config.to_prepare do
13
+ Listenable.cleanup!
14
+ end
15
+
16
+ # Graceful shutdown on Rails exit
17
+ config.after_initialize do
18
+ at_exit do
19
+ Listenable.shutdown_async_executor!
20
+ end
21
+ end
22
+
5
23
  initializer 'listenable.load' do
6
24
  Rails.application.config.to_prepare do
7
25
  # Load all listeners (recursive, supports namespaced)
8
- Dir[Rails.root.join('app/listeners/**/*.rb')].each { |f| require_dependency f }
26
+ Dir[Rails.root.join('app/listeners/**/*.rb')].each do |file|
27
+ require_dependency file
28
+ end
9
29
 
10
- # Find all listener classes
11
- ObjectSpace.each_object(Class).select { |klass| klass < Listenable }.each do |listener_class|
30
+ Listenable.listener_classes.each do |listener_class|
12
31
  model_class_name = listener_class.name.sub('Listener', '')
13
32
  model_class = model_class_name.safe_constantize
14
33
  next unless model_class
15
34
 
35
+ injected_events =
36
+ model_class.instance_variable_get(:@_listenable_injected_events) || []
37
+
16
38
  listener_class.pending_hooks.each do |hook_info|
17
39
  hook = hook_info[:name]
18
40
  async = hook_info[:async]
19
41
  action = hook.sub('on_', '')
20
- callback = Listenable::CALLBACK_MAP[action] or next
21
42
  method = "on_#{action}"
22
43
  event = "#{model_class_name.underscore}.#{action}"
23
44
 
24
- # unsubscribe old subscribers
25
- ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
26
- ActiveSupport::Notifications.unsubscribe(subscriber)
27
- end
45
+ next unless listener_class.respond_to?(method)
28
46
 
29
- injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
47
+ # Inject callback once per model
30
48
  unless injected_events.include?(event)
31
- 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
32
53
  next unless Listenable.enabled
33
54
 
34
- ActiveSupport::Notifications.instrument(event, record: self)
55
+ ActiveSupport::Notifications.instrument(
56
+ event,
57
+ record_class: self.class,
58
+ record_id: id
59
+ )
35
60
  end
61
+
36
62
  injected_events << event
37
- model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
63
+ model_class.instance_variable_set(
64
+ :@_listenable_injected_events,
65
+ injected_events
66
+ )
38
67
  end
39
68
 
40
- next unless listener_class.respond_to?(method)
41
-
42
- ActiveSupport::Notifications.subscribe(event) do |*args|
69
+ # Subscribe (only once per reload)
70
+ subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
43
71
  next unless Listenable.enabled
44
72
 
45
73
  _name, _start, _finish, _id, payload = args
46
- record = payload[:record]
47
74
 
48
75
  if async
49
- Concurrent::Promises.future do
50
- listener_class.public_send(method, record)
51
- end.rescue do |e|
52
- Rails.logger.error("[Listenable] #{listener_class}##{method} failed: #{e.message}")
53
- end
76
+ Railtie.handle_async_listener(
77
+ listener_class,
78
+ method,
79
+ payload[:record_class],
80
+ payload[:record_id]
81
+ )
54
82
  else
55
- listener_class.public_send(method, record)
83
+ Railtie.handle_sync_listener(
84
+ listener_class,
85
+ method,
86
+ payload[:record_class],
87
+ payload[:record_id]
88
+ )
56
89
  end
57
90
  end
91
+
92
+ Listenable.subscribers << subscriber
58
93
  end
59
94
  end
60
95
  end
61
96
  end
97
+
98
+ class << self
99
+ def handle_async_listener(listener_class, method, record_class, record_id)
100
+ Concurrent::Promises.future_on(Listenable.async_executor) do
101
+ ActiveRecord::Base.connection_pool.with_connection do
102
+ execute_listener(listener_class, method, record_class, record_id)
103
+ end
104
+ rescue ActiveRecord::ConnectionTimeoutError => e
105
+ Rails.logger&.error(
106
+ "[Listenable] DB pool exhausted for #{listener_class}##{method}: #{e.message}"
107
+ )
108
+ rescue StandardError => e
109
+ log_error(listener_class, method, e)
110
+ end
111
+ end
112
+
113
+ def handle_sync_listener(listener_class, method, record_class, record_id)
114
+ execute_listener(listener_class, method, record_class, record_id)
115
+ rescue StandardError => e
116
+ log_error(listener_class, method, e)
117
+ raise
118
+ end
119
+
120
+ private
121
+
122
+ def execute_listener(listener_class, method, record_class, record_id)
123
+ record = record_class.find_by(id: record_id)
124
+
125
+ unless record
126
+ Rails.logger&.warn(
127
+ "[Listenable] #{record_class}##{record_id} not found for #{listener_class}##{method}"
128
+ )
129
+ return
130
+ end
131
+
132
+ listener_class.public_send(method, record)
133
+ end
134
+
135
+ def log_error(listener_class, method, error)
136
+ Rails.logger&.error(
137
+ "[Listenable] #{listener_class}##{method} failed: " \
138
+ "#{error.message}\n#{error.backtrace.first(5).join("\n")}"
139
+ )
140
+ end
141
+ end
62
142
  end
63
143
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Listenable
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.1'
5
5
  end
data/lib/listenable.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require 'active_support'
4
4
  require 'active_support/concern'
5
5
  require 'active_support/notifications'
6
+ require 'concurrent'
7
+ require 'set'
6
8
 
7
9
  require_relative 'listenable/version'
8
10
  require_relative 'listenable/concern'
@@ -11,6 +13,96 @@ require_relative 'listenable/railtie' if defined?(Rails)
11
13
  module Listenable
12
14
  mattr_accessor :enabled, default: true
13
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
+
14
21
  class Error < StandardError; end
15
- # Your code goes here...
22
+ class ConnectionPoolExhausted < Error; end
23
+
24
+ class << self
25
+ attr_writer :async_executor
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
+
42
+ # Track active subscribers to prevent memory leaks on reload
43
+ def subscribers
44
+ @subscribers ||= []
45
+ end
46
+
47
+ # Calculate a safe thread pool size based on connection pool
48
+ # Very conservative: use configurable ratio of pool (default 25%)
49
+ def default_thread_pool_size
50
+ return 2 unless defined?(ActiveRecord::Base)
51
+
52
+ pool_size = ActiveRecord::Base.connection_pool.size
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
55
+ end
56
+
57
+ # Thread pool executor for async listeners
58
+ # Auto-scales to 25% of connection pool (very conservative)
59
+ def async_executor
60
+ @async_executor ||= Concurrent::ThreadPoolExecutor.new(
61
+ min_threads: 0,
62
+ max_threads: default_thread_pool_size,
63
+ max_queue: 10_000,
64
+ fallback_policy: :caller_runs,
65
+ idletime: 60 # Threads idle for 60s are cleaned up
66
+ )
67
+ end
68
+
69
+ # Cleanup all subscribers and shutdown thread pool
70
+ # Called on Rails reload to prevent memory leaks
71
+ def cleanup!
72
+ # Unsubscribe all tracked subscribers
73
+ subscribers.each do |subscriber|
74
+ ActiveSupport::Notifications.unsubscribe(subscriber)
75
+ rescue StandardError => e
76
+ Rails.logger&.warn("[Listenable] Failed to unsubscribe: #{e.message}")
77
+ end
78
+ @subscribers = []
79
+
80
+ # Clear listener registry for fresh reload
81
+ clear_listeners!
82
+
83
+ # Shutdown thread pool gracefully
84
+ shutdown_async_executor!
85
+ end
86
+
87
+ # Graceful shutdown of thread pool
88
+ def shutdown_async_executor!
89
+ return unless @async_executor
90
+
91
+ @async_executor.shutdown
92
+ unless @async_executor.wait_for_termination(10)
93
+ Rails.logger&.warn('[Listenable] Thread pool shutdown timeout, forcing kill')
94
+ @async_executor.kill
95
+ end
96
+ @async_executor = nil
97
+ end
98
+
99
+ # For testing - immediate shutdown
100
+ def reset_async_executor!
101
+ return unless @async_executor
102
+
103
+ @async_executor.shutdown
104
+ @async_executor.kill unless @async_executor.wait_for_termination(5)
105
+ @async_executor = nil
106
+ end
107
+ end
16
108
  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.2.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.7.1
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: []