listenable 0.2.0 → 0.3.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: 39c0faefb1a4b56eb6e359bc950a75bad47be727181b3fa7e751722c737a9fb3
4
- data.tar.gz: 271cb3a4ccf5b357943967e9c503e8c6344be7e10e0439b7f6b0838b205fa228
3
+ metadata.gz: a40496b36e34ee9be039eafd3ca5ce235d7fff2c2ccd93dfb1041bd75fa5b87f
4
+ data.tar.gz: 89505e376d1f337dd5e9d7172383a07501846613fcf556fa94682383b2a42d85
5
5
  SHA512:
6
- metadata.gz: 4012acf44c48b3ebbc64060fe4488f81efd61eac703f476d20447ca6270003df1ddb5b27a997c43a59474827a2c2ab0bb0e92b18226379a7e19a3325540d8830
7
- data.tar.gz: a8046f1f29c00b3b2db7fa90603c5b6acf69ccf868fdd84ce7064f1964a6e112e44cd9749a67c5bd538064b72bc4112e203cfd47674918945a97d6e792c57c9e
6
+ metadata.gz: 7b2a55fee58e33e6f9f3fe1354d67a86668c93294e9e94274fba575ebe415805952f5e53179bf0fd041d00ab361e4c01b07390df3ac4a1f1eebd82eddf50505b
7
+ data.tar.gz: ef07a946e9bfc66eb038d09b765977cf44e766e2209295d2427bb7f3ad24e35d9827a3c473905d2baecb10f28814e344600ba857650e453c976622604f65074b
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
 
@@ -2,13 +2,27 @@
2
2
 
3
3
  module Listenable
4
4
  class Railtie < Rails::Railtie
5
+ # Cleanup on Rails reload to prevent memory leaks
6
+ config.to_prepare do
7
+ Listenable.cleanup!
8
+ end
9
+
10
+ # Graceful shutdown on Rails exit
11
+ config.after_initialize do
12
+ at_exit do
13
+ Listenable.shutdown_async_executor!
14
+ end
15
+ end
16
+
5
17
  initializer 'listenable.load' do
6
18
  Rails.application.config.to_prepare do
7
19
  # Load all listeners (recursive, supports namespaced)
8
- Dir[Rails.root.join('app/listeners/**/*.rb')].each { |f| require_dependency f }
20
+ listener_files = Dir[Rails.root.join('app/listeners/**/*.rb')]
21
+ listener_files.each { |f| require_dependency f }
9
22
 
10
23
  # Find all listener classes
11
- ObjectSpace.each_object(Class).select { |klass| klass < Listenable }.each do |listener_class|
24
+ listener_classes = ObjectSpace.each_object(Class).select { |klass| klass < Listenable }
25
+ listener_classes.each do |listener_class|
12
26
  model_class_name = listener_class.name.sub('Listener', '')
13
27
  model_class = model_class_name.safe_constantize
14
28
  next unless model_class
@@ -39,25 +53,76 @@ module Listenable
39
53
 
40
54
  next unless listener_class.respond_to?(method)
41
55
 
42
- ActiveSupport::Notifications.subscribe(event) do |*args|
56
+ # Subscribe and track subscriber for cleanup
57
+ subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
43
58
  next unless Listenable.enabled
44
59
 
45
60
  _name, _start, _finish, _id, payload = args
46
61
  record = payload[:record]
47
62
 
48
63
  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
64
+ Railtie.handle_async_listener(listener_class, method, record)
54
65
  else
55
- listener_class.public_send(method, record)
66
+ Railtie.handle_sync_listener(listener_class, method, record)
56
67
  end
57
68
  end
69
+
70
+ # Track subscriber for cleanup on reload
71
+ Listenable.subscribers << subscriber
58
72
  end
59
73
  end
60
74
  end
61
75
  end
76
+
77
+ 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
85
+ Concurrent::Promises.future_on(Listenable.async_executor) do
86
+ # Wrap in connection pool management to prevent connection exhaustion
87
+ ActiveRecord::Base.connection_pool.with_connection do
88
+ execute_listener(listener_class, method, record_class, record_id)
89
+ rescue StandardError => e
90
+ log_error(listener_class, method, e)
91
+ end
92
+ end.rescue do |e|
93
+ Rails.logger&.error(
94
+ "[Listenable] Promise failed for #{listener_class}##{method}: #{e.message}"
95
+ )
96
+ end
97
+ end
98
+
99
+ # Handle sync listener with proper error handling
100
+ def handle_sync_listener(listener_class, method, record)
101
+ listener_class.public_send(method, record)
102
+ rescue StandardError => e
103
+ log_error(listener_class, method, e)
104
+ raise # Re-raise for sync listeners to maintain transaction integrity
105
+ end
106
+
107
+ private
108
+
109
+ def execute_listener(listener_class, method, record_class, record_id)
110
+ reloaded_record = record_class.find_by(id: record_id)
111
+
112
+ if reloaded_record
113
+ listener_class.public_send(method, reloaded_record)
114
+ else
115
+ Rails.logger&.warn(
116
+ "[Listenable] Record #{record_class}##{record_id} not found for #{listener_class}##{method}"
117
+ )
118
+ end
119
+ end
120
+
121
+ def log_error(listener_class, method, error)
122
+ Rails.logger&.error(
123
+ "[Listenable] #{listener_class}##{method} failed: #{error.message}\n#{error.backtrace.first(5).join("\n")}"
124
+ )
125
+ end
126
+ end
62
127
  end
63
128
  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.0'
5
5
  end
data/lib/listenable.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'active_support'
4
4
  require 'active_support/concern'
5
5
  require 'active_support/notifications'
6
+ require 'concurrent'
6
7
 
7
8
  require_relative 'listenable/version'
8
9
  require_relative 'listenable/concern'
@@ -12,5 +13,71 @@ module Listenable
12
13
  mattr_accessor :enabled, default: true
13
14
 
14
15
  class Error < StandardError; end
15
- # Your code goes here...
16
+
17
+ class << self
18
+ attr_writer :async_executor
19
+
20
+ # Track active subscribers to prevent memory leaks on reload
21
+ def subscribers
22
+ @subscribers ||= []
23
+ end
24
+
25
+ # Calculate a safe thread pool size based on connection pool
26
+ # Very conservative: use only 1/4 of pool (min 1, max 3)
27
+ def default_thread_pool_size
28
+ return 2 unless defined?(ActiveRecord::Base)
29
+
30
+ 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
33
+ end
34
+
35
+ # Thread pool executor for async listeners
36
+ # Auto-scales to 25% of connection pool (very conservative)
37
+ def async_executor
38
+ @async_executor ||= Concurrent::ThreadPoolExecutor.new(
39
+ min_threads: 0,
40
+ max_threads: default_thread_pool_size,
41
+ max_queue: 10_000,
42
+ fallback_policy: :caller_runs,
43
+ idletime: 60 # Threads idle for 60s are cleaned up
44
+ )
45
+ end
46
+
47
+ # Cleanup all subscribers and shutdown thread pool
48
+ # Called on Rails reload to prevent memory leaks
49
+ def cleanup!
50
+ # Unsubscribe all tracked subscribers
51
+ subscribers.each do |subscriber|
52
+ ActiveSupport::Notifications.unsubscribe(subscriber)
53
+ rescue StandardError => e
54
+ Rails.logger&.warn("[Listenable] Failed to unsubscribe: #{e.message}")
55
+ end
56
+ @subscribers = []
57
+
58
+ # Shutdown thread pool gracefully
59
+ shutdown_async_executor!
60
+ end
61
+
62
+ # Graceful shutdown of thread pool
63
+ def shutdown_async_executor!
64
+ return unless @async_executor
65
+
66
+ @async_executor.shutdown
67
+ unless @async_executor.wait_for_termination(10)
68
+ Rails.logger&.warn('[Listenable] Thread pool shutdown timeout, forcing kill')
69
+ @async_executor.kill
70
+ end
71
+ @async_executor = nil
72
+ end
73
+
74
+ # For testing - immediate shutdown
75
+ def reset_async_executor!
76
+ return unless @async_executor
77
+
78
+ @async_executor.shutdown
79
+ @async_executor.kill unless @async_executor.wait_for_termination(5)
80
+ @async_executor = nil
81
+ end
82
+ end
16
83
  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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Den Meralpis
@@ -118,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
118
118
  - !ruby/object:Gem::Version
119
119
  version: '0'
120
120
  requirements: []
121
- rubygems_version: 3.7.1
121
+ rubygems_version: 3.6.9
122
122
  specification_version: 4
123
123
  summary: A Rails DSL for model event listeners using ActiveSupport::Notifications.
124
124
  test_files: []