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 +4 -4
- data/README.md +118 -68
- data/lib/listenable/railtie.rb +74 -9
- data/lib/listenable/version.rb +1 -1
- data/lib/listenable.rb +68 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a40496b36e34ee9be039eafd3ca5ce235d7fff2c2ccd93dfb1041bd75fa5b87f
|
|
4
|
+
data.tar.gz: 89505e376d1f337dd5e9d7172383a07501846613fcf556fa94682383b2a42d85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
data/lib/listenable/railtie.rb
CHANGED
|
@@ -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')]
|
|
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 }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/listenable/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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.
|
|
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: []
|