listenable 0.1.1 → 0.2.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 +152 -11
- data/Rakefile +3 -3
- data/lib/listenable/concern.rb +8 -5
- data/lib/listenable/railtie.rb +25 -13
- data/lib/listenable/version.rb +1 -1
- data/lib/listenable.rb +6 -6
- metadata +57 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39c0faefb1a4b56eb6e359bc950a75bad47be727181b3fa7e751722c737a9fb3
|
|
4
|
+
data.tar.gz: 271cb3a4ccf5b357943967e9c503e8c6344be7e10e0439b7f6b0838b205fa228
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4012acf44c48b3ebbc64060fe4488f81efd61eac703f476d20447ca6270003df1ddb5b27a997c43a59474827a2c2ab0bb0e92b18226379a7e19a3325540d8830
|
|
7
|
+
data.tar.gz: a8046f1f29c00b3b2db7fa90603c5b6acf69ccf868fdd84ce7064f1964a6e112e44cd9749a67c5bd538064b72bc4112e203cfd47674918945a97d6e792c57c9e
|
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,12 +67,83 @@ 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
|
|
|
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
|
+
|
|
68
139
|
## Supported hooks
|
|
69
|
-
| Listener hook | Model callback |
|
|
70
|
-
|
|
71
|
-
| `on_created` | `after_create` |
|
|
72
|
-
| `on_updated` | `after_update` |
|
|
73
|
-
| `on_deleted` | `after_destroy` |
|
|
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.
|
|
74
147
|
|
|
75
148
|
## Runtime Toggle
|
|
76
149
|
By default, listeners are always active in development and production.
|
|
@@ -115,13 +188,82 @@ RSpec.describe User do
|
|
|
115
188
|
User.create!(name: 'Pedro')
|
|
116
189
|
end
|
|
117
190
|
|
|
118
|
-
it 'fires listeners when enabled', listenable: true do
|
|
191
|
+
it 'fires synchronous listeners when enabled', listenable: true do
|
|
119
192
|
expect(UserListener).to receive(:on_created)
|
|
120
193
|
User.create!(name: 'Pedro')
|
|
121
194
|
end
|
|
122
195
|
end
|
|
123
196
|
```
|
|
124
197
|
|
|
198
|
+
## ⚠️ Important: Execution Modes and Performance
|
|
199
|
+
|
|
200
|
+
### Synchronous Listeners (Default Behavior)
|
|
201
|
+
|
|
202
|
+
**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.
|
|
203
|
+
|
|
204
|
+
**For synchronous listeners**: Always queue heavy operations in background jobs to maintain application performance:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class UserListener
|
|
208
|
+
include Listenable
|
|
209
|
+
|
|
210
|
+
# Synchronous listeners (default)
|
|
211
|
+
listen :on_created, :on_updated
|
|
212
|
+
|
|
213
|
+
def self.on_created(user)
|
|
214
|
+
# ✅ Good - Lightweight operations or queue background jobs
|
|
215
|
+
SendWelcomeEmailJob.perform_later(user)
|
|
216
|
+
NotifyAdminsJob.perform_later(user)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.on_updated(user)
|
|
220
|
+
# ❌ Avoid - Heavy synchronous operations that block requests
|
|
221
|
+
# UserAnalyticsService.new(user).calculate_metrics # This blocks!
|
|
222
|
+
|
|
223
|
+
# ✅ Better - Queue in background
|
|
224
|
+
CalculateUserMetricsJob.perform_later(user)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Asynchronous Listeners (Non-blocking)
|
|
230
|
+
|
|
231
|
+
**Asynchronous listeners execute in separate threads and don't block requests.** This allows for heavier operations without impacting response times:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class UserListener
|
|
235
|
+
include Listenable
|
|
236
|
+
|
|
237
|
+
# Asynchronous listeners - safe for heavier operations
|
|
238
|
+
listen :on_created, :on_updated, async: true
|
|
239
|
+
|
|
240
|
+
def self.on_created(user)
|
|
241
|
+
# ✅ Safe - Runs in background thread
|
|
242
|
+
UserAnalyticsService.new(user).calculate_metrics
|
|
243
|
+
SendWelcomeEmailService.call(user)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.on_updated(user)
|
|
247
|
+
# ✅ Safe - Heavy operations won't block requests
|
|
248
|
+
ExternalApiService.notify_user_update(user)
|
|
249
|
+
GenerateUserReportService.call(user)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**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
|
+
|
|
256
|
+
### Choosing the Right Mode
|
|
257
|
+
|
|
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
|
|
262
|
+
|
|
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
|
|
125
267
|
|
|
126
268
|
## Development
|
|
127
269
|
|
|
@@ -131,7 +273,6 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
131
273
|
|
|
132
274
|
## Todo:
|
|
133
275
|
* Create rake tasks to generate listener files.
|
|
134
|
-
* RSpec tests for Railtie and integration tests.
|
|
135
276
|
|
|
136
277
|
## Contributing
|
|
137
278
|
|
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,45 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
module Listenable
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
-
initializer
|
|
5
|
+
initializer 'listenable.load' do
|
|
6
6
|
Rails.application.config.to_prepare do
|
|
7
7
|
# Load all listeners (recursive, supports namespaced)
|
|
8
|
-
Dir[Rails.root.join(
|
|
8
|
+
Dir[Rails.root.join('app/listeners/**/*.rb')].each { |f| require_dependency f }
|
|
9
9
|
|
|
10
10
|
# Find all listener classes
|
|
11
11
|
ObjectSpace.each_object(Class).select { |klass| klass < Listenable }.each do |listener_class|
|
|
12
|
-
model_class_name = listener_class.name.sub(
|
|
12
|
+
model_class_name = listener_class.name.sub('Listener', '')
|
|
13
13
|
model_class = model_class_name.safe_constantize
|
|
14
14
|
next unless model_class
|
|
15
15
|
|
|
16
|
-
listener_class.pending_hooks.each do |
|
|
17
|
-
|
|
16
|
+
listener_class.pending_hooks.each do |hook_info|
|
|
17
|
+
hook = hook_info[:name]
|
|
18
|
+
async = hook_info[:async]
|
|
19
|
+
action = hook.sub('on_', '')
|
|
18
20
|
callback = Listenable::CALLBACK_MAP[action] or next
|
|
19
21
|
method = "on_#{action}"
|
|
20
22
|
event = "#{model_class_name.underscore}.#{action}"
|
|
21
23
|
|
|
22
|
-
#
|
|
24
|
+
# unsubscribe old subscribers
|
|
23
25
|
ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
|
|
24
26
|
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
# Inject AR callback once per model/event
|
|
28
29
|
injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
|
|
29
30
|
unless injected_events.include?(event)
|
|
30
31
|
model_class.send(callback) do
|
|
31
32
|
next unless Listenable.enabled
|
|
33
|
+
|
|
32
34
|
ActiveSupport::Notifications.instrument(event, record: self)
|
|
33
35
|
end
|
|
34
36
|
injected_events << event
|
|
35
37
|
model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
next unless listener_class.respond_to?(method)
|
|
41
|
+
|
|
42
|
+
ActiveSupport::Notifications.subscribe(event) do |*args|
|
|
43
|
+
next unless Listenable.enabled
|
|
44
|
+
|
|
45
|
+
_name, _start, _finish, _id, payload = args
|
|
46
|
+
record = payload[:record]
|
|
47
|
+
|
|
48
|
+
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
|
|
54
|
+
else
|
|
55
|
+
listener_class.public_send(method, record)
|
|
44
56
|
end
|
|
45
57
|
end
|
|
46
58
|
end
|
data/lib/listenable/version.rb
CHANGED
data/lib/listenable.rb
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
6
|
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
7
|
+
require_relative 'listenable/version'
|
|
8
|
+
require_relative 'listenable/concern'
|
|
9
|
+
require_relative 'listenable/railtie' if defined?(Rails)
|
|
10
10
|
|
|
11
11
|
module Listenable
|
|
12
12
|
mattr_accessor :enabled, default: true
|
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.2.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.
|