listenable 0.1.1 → 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 +205 -14
- data/Rakefile +3 -3
- data/lib/listenable/concern.rb +8 -5
- data/lib/listenable/railtie.rb +91 -14
- data/lib/listenable/version.rb +1 -1
- data/lib/listenable.rb +74 -7
- metadata +58 -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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Listenable is a Rails DSL that connects your ActiveRecord models to dedicated listener classes using `ActiveSupport::Notifications`.
|
|
4
4
|
|
|
5
|
-
Instead of cluttering your models with callbacks, you declare listeners in `app/listeners`. Listenable automatically wires up the callbacks, instruments events, and runs your listener methods.
|
|
5
|
+
Instead of cluttering your models with callbacks, you declare listeners in `app/listeners`. Listenable automatically wires up the callbacks, instruments events, and runs your listener methods. It supports both synchronous (blocking) and asynchronous (non-blocking) execution modes.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -18,6 +18,8 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
|
18
18
|
gem install listenable
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
**Note**: For asynchronous listener support, make sure you have the `concurrent-ruby` gem installed (usually included with Rails by default).
|
|
22
|
+
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
23
25
|
#### 1. Define a model
|
|
@@ -36,19 +38,19 @@ class UserListener
|
|
|
36
38
|
listen :on_created, :on_updated, :on_deleted
|
|
37
39
|
|
|
38
40
|
# Handle user creation
|
|
39
|
-
def on_created(record)
|
|
41
|
+
def self.on_created(record)
|
|
40
42
|
Rails.logger.info "User created: #{user.id}"
|
|
41
43
|
SendWelcomeEmailJob.perform_later(user)
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# Handle user update
|
|
45
|
-
def on_updated(record)
|
|
47
|
+
def self.on_updated(record)
|
|
46
48
|
Rails.logger.info "User updated: #{user.id}"
|
|
47
49
|
SendProfileUpdateNotificationJob.perform_later(user)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# Handle user deletion
|
|
51
|
-
def on_deleted(record)
|
|
53
|
+
def self.on_deleted(record)
|
|
52
54
|
Rails.logger.info "User deleted: #{user.id}"
|
|
53
55
|
ArchiveUserDataJob.perform_later(user)
|
|
54
56
|
end
|
|
@@ -65,14 +67,205 @@ Under the hood:
|
|
|
65
67
|
* `ActiveSupport::Notifications.instrument` fires events like `user.created`.
|
|
66
68
|
* The Railtie subscribes your listener methods to those events.
|
|
67
69
|
|
|
68
|
-
##
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
## Synchronous vs Asynchronous Execution
|
|
71
|
+
|
|
72
|
+
Listenable supports both synchronous (blocking) and asynchronous (non-blocking) listener execution:
|
|
73
|
+
|
|
74
|
+
### Synchronous Listeners (Default)
|
|
75
|
+
By default, listeners execute synchronously in the same thread as your model operations:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class UserListener
|
|
79
|
+
include Listenable
|
|
80
|
+
|
|
81
|
+
# Synchronous execution (default)
|
|
82
|
+
listen :on_created, :on_updated, :on_deleted
|
|
83
|
+
|
|
84
|
+
def self.on_created(user)
|
|
85
|
+
Rails.logger.info "User created: #{user.id}"
|
|
86
|
+
# This runs in the same request thread
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Asynchronous Listeners
|
|
92
|
+
For non-blocking execution, use the `async: true` option:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class UserListener
|
|
96
|
+
include Listenable
|
|
97
|
+
|
|
98
|
+
# Asynchronous execution - runs in background thread
|
|
99
|
+
listen :on_created, :on_updated, :on_deleted, async: true
|
|
100
|
+
|
|
101
|
+
def self.on_created(user)
|
|
102
|
+
Rails.logger.info "User created: #{user.id}"
|
|
103
|
+
# This runs in a separate thread, doesn't block the request
|
|
104
|
+
SendWelcomeEmailService.call(user) # Safe for heavier operations
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Mixed Execution Modes
|
|
110
|
+
You can mix synchronous and asynchronous listeners by calling `listen` multiple times:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
class UserListener
|
|
114
|
+
include Listenable
|
|
115
|
+
|
|
116
|
+
# Some listeners run synchronously
|
|
117
|
+
listen :on_created
|
|
118
|
+
|
|
119
|
+
# Others run asynchronously
|
|
120
|
+
listen :on_updated, :on_deleted, async: true
|
|
121
|
+
|
|
122
|
+
def self.on_created(user)
|
|
123
|
+
# Runs synchronously - blocks request
|
|
124
|
+
user.update!(status: 'active')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.on_updated(user)
|
|
128
|
+
# Runs asynchronously - doesn't block request
|
|
129
|
+
UserAnalyticsService.new(user).calculate_metrics
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.on_deleted(user)
|
|
133
|
+
# Also runs asynchronously
|
|
134
|
+
CleanupUserDataService.call(user)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## ⚠️ Important: Execution Modes and Performance
|
|
140
|
+
|
|
141
|
+
### Synchronous Listeners (Default Behavior)
|
|
142
|
+
|
|
143
|
+
**Synchronous listeners execute in the same thread and will block the current request.** This means that all synchronous listener methods run in the same request/transaction as your model operations, which can impact performance and response times.
|
|
144
|
+
|
|
145
|
+
**For synchronous listeners**: Always queue heavy operations in background jobs to maintain application performance:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class UserListener
|
|
149
|
+
include Listenable
|
|
150
|
+
|
|
151
|
+
# Synchronous listeners (default)
|
|
152
|
+
listen :on_created, :on_updated
|
|
153
|
+
|
|
154
|
+
def self.on_created(user)
|
|
155
|
+
# ✅ Good - Lightweight operations or queue background jobs
|
|
156
|
+
SendWelcomeEmailJob.perform_later(user)
|
|
157
|
+
NotifyAdminsJob.perform_later(user)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.on_updated(user)
|
|
161
|
+
# ❌ Avoid - Heavy synchronous operations that block requests
|
|
162
|
+
# UserAnalyticsService.new(user).calculate_metrics # This blocks!
|
|
163
|
+
|
|
164
|
+
# ✅ Better - Queue in background
|
|
165
|
+
CalculateUserMetricsJob.perform_later(user)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Asynchronous Listeners (Non-blocking)
|
|
171
|
+
|
|
172
|
+
**Asynchronous listeners execute in separate threads and don't block requests.** This allows for heavier operations without impacting response times:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class UserListener
|
|
176
|
+
include Listenable
|
|
177
|
+
|
|
178
|
+
# Asynchronous listeners - safe for heavier operations
|
|
179
|
+
listen :on_created, :on_updated, async: true
|
|
180
|
+
|
|
181
|
+
def self.on_created(user)
|
|
182
|
+
# ✅ Safe - Runs in background thread
|
|
183
|
+
UserAnalyticsService.new(user).calculate_metrics
|
|
184
|
+
SendWelcomeEmailService.call(user)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.on_updated(user)
|
|
188
|
+
# ✅ Safe - Heavy operations won't block requests
|
|
189
|
+
ExternalApiService.notify_user_update(user)
|
|
190
|
+
GenerateUserReportService.call(user)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
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.
|
|
196
|
+
|
|
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
|
|
74
206
|
|
|
75
|
-
|
|
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
|
|
222
|
+
|
|
223
|
+
Async listeners use a **bounded thread pool** that automatically scales based on your database connection pool size:
|
|
224
|
+
|
|
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.
|
|
76
269
|
By default, listeners are always active in development and production.
|
|
77
270
|
|
|
78
271
|
You can enable/disable them dynamically at runtime using:
|
|
@@ -115,14 +308,13 @@ RSpec.describe User do
|
|
|
115
308
|
User.create!(name: 'Pedro')
|
|
116
309
|
end
|
|
117
310
|
|
|
118
|
-
it 'fires listeners when enabled', listenable: true do
|
|
311
|
+
it 'fires synchronous listeners when enabled', listenable: true do
|
|
119
312
|
expect(UserListener).to receive(:on_created)
|
|
120
313
|
User.create!(name: 'Pedro')
|
|
121
314
|
end
|
|
122
315
|
end
|
|
123
316
|
```
|
|
124
317
|
|
|
125
|
-
|
|
126
318
|
## Development
|
|
127
319
|
|
|
128
320
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -131,7 +323,6 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
131
323
|
|
|
132
324
|
## Todo:
|
|
133
325
|
* Create rake tasks to generate listener files.
|
|
134
|
-
* RSpec tests for Railtie and integration tests.
|
|
135
326
|
|
|
136
327
|
## Contributing
|
|
137
328
|
|
data/Rakefile
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
require
|
|
8
|
+
require 'rubocop/rake_task'
|
|
9
9
|
|
|
10
10
|
RuboCop::RakeTask.new
|
|
11
11
|
|
data/lib/listenable/concern.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Listenable
|
|
4
4
|
CALLBACK_MAP = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
'created' => :after_create,
|
|
6
|
+
'updated' => :after_update,
|
|
7
|
+
'deleted' => :after_destroy
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
10
|
def self.included(base)
|
|
@@ -12,9 +12,12 @@ module Listenable
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
module ClassMethods
|
|
15
|
-
def listen(*hooks)
|
|
15
|
+
def listen(*hooks, async: false)
|
|
16
16
|
@pending_hooks ||= []
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
hooks.each do |hook|
|
|
19
|
+
@pending_hooks << { name: hook.to_s, async: async }
|
|
20
|
+
end
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def pending_hooks
|
data/lib/listenable/railtie.rb
CHANGED
|
@@ -2,50 +2,127 @@
|
|
|
2
2
|
|
|
3
3
|
module Listenable
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
-
|
|
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
|
+
|
|
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(
|
|
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 }
|
|
12
|
-
|
|
24
|
+
listener_classes = ObjectSpace.each_object(Class).select { |klass| klass < Listenable }
|
|
25
|
+
listener_classes.each do |listener_class|
|
|
26
|
+
model_class_name = listener_class.name.sub('Listener', '')
|
|
13
27
|
model_class = model_class_name.safe_constantize
|
|
14
28
|
next unless model_class
|
|
15
29
|
|
|
16
|
-
listener_class.pending_hooks.each do |
|
|
17
|
-
|
|
30
|
+
listener_class.pending_hooks.each do |hook_info|
|
|
31
|
+
hook = hook_info[:name]
|
|
32
|
+
async = hook_info[:async]
|
|
33
|
+
action = hook.sub('on_', '')
|
|
18
34
|
callback = Listenable::CALLBACK_MAP[action] or next
|
|
19
35
|
method = "on_#{action}"
|
|
20
36
|
event = "#{model_class_name.underscore}.#{action}"
|
|
21
37
|
|
|
22
|
-
#
|
|
38
|
+
# unsubscribe old subscribers
|
|
23
39
|
ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
|
|
24
40
|
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
25
41
|
end
|
|
26
42
|
|
|
27
|
-
# Inject AR callback once per model/event
|
|
28
43
|
injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
|
|
29
44
|
unless injected_events.include?(event)
|
|
30
45
|
model_class.send(callback) do
|
|
31
46
|
next unless Listenable.enabled
|
|
47
|
+
|
|
32
48
|
ActiveSupport::Notifications.instrument(event, record: self)
|
|
33
49
|
end
|
|
34
50
|
injected_events << event
|
|
35
51
|
model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
|
|
36
52
|
end
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
next unless listener_class.respond_to?(method)
|
|
55
|
+
|
|
56
|
+
# Subscribe and track subscriber for cleanup
|
|
57
|
+
subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
|
|
58
|
+
next unless Listenable.enabled
|
|
59
|
+
|
|
60
|
+
_name, _start, _finish, _id, payload = args
|
|
61
|
+
record = payload[:record]
|
|
62
|
+
|
|
63
|
+
if async
|
|
64
|
+
Railtie.handle_async_listener(listener_class, method, record)
|
|
65
|
+
else
|
|
66
|
+
Railtie.handle_sync_listener(listener_class, method, record)
|
|
44
67
|
end
|
|
45
68
|
end
|
|
69
|
+
|
|
70
|
+
# Track subscriber for cleanup on reload
|
|
71
|
+
Listenable.subscribers << subscriber
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
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)
|
|
46
91
|
end
|
|
92
|
+
end.rescue do |e|
|
|
93
|
+
Rails.logger&.error(
|
|
94
|
+
"[Listenable] Promise failed for #{listener_class}##{method}: #{e.message}"
|
|
95
|
+
)
|
|
47
96
|
end
|
|
48
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
|
|
49
126
|
end
|
|
50
127
|
end
|
|
51
128
|
end
|
data/lib/listenable/version.rb
CHANGED
data/lib/listenable.rb
CHANGED
|
@@ -1,16 +1,83 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/concern'
|
|
5
|
+
require 'active_support/notifications'
|
|
6
|
+
require 'concurrent'
|
|
6
7
|
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
8
|
+
require_relative 'listenable/version'
|
|
9
|
+
require_relative 'listenable/concern'
|
|
10
|
+
require_relative 'listenable/railtie' if defined?(Rails)
|
|
10
11
|
|
|
11
12
|
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
|
|
@@ -23,6 +23,62 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '6.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: concurrent-ruby
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: activerecord
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rails
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '6.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '6.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: sqlite3
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.1'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.1'
|
|
26
82
|
description: Listenable makes it easy to wire ActiveRecord models to listener classes.
|
|
27
83
|
Define <Model>Listener classes in app/listeners, declare listen :on_created, :on_updated,
|
|
28
84
|
etc., and Listenable automatically injects callbacks and subscribes to ActiveSupport::Notifications.
|
|
@@ -62,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
62
118
|
- !ruby/object:Gem::Version
|
|
63
119
|
version: '0'
|
|
64
120
|
requirements: []
|
|
65
|
-
rubygems_version: 3.
|
|
121
|
+
rubygems_version: 3.6.9
|
|
66
122
|
specification_version: 4
|
|
67
123
|
summary: A Rails DSL for model event listeners using ActiveSupport::Notifications.
|
|
68
124
|
test_files: []
|