listenable 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa0682e0a477e1a2367d552bab4b480cfdb2d902628cc0f3eff9b566750332b4
4
- data.tar.gz: 81f80897906e86612474240bf7b1dedb2e7e21e4572d4313e61e65208f830d64
3
+ metadata.gz: 39c0faefb1a4b56eb6e359bc950a75bad47be727181b3fa7e751722c737a9fb3
4
+ data.tar.gz: 271cb3a4ccf5b357943967e9c503e8c6344be7e10e0439b7f6b0838b205fa228
5
5
  SHA512:
6
- metadata.gz: 781cc4d59e3a98604a8c2e06a5d0d981513ab08814949695b0da3b4ff4d9bb3ce737386e414969ae13e1fca1bee2b67708a12f71d7b46a9f76fe2b19c4c961f3
7
- data.tar.gz: c2ae5d198f292ea9fb2c9792da90cc0bb9a97ec950b48dbf79090a2c0ddbdef832289a807b958d9f83739d8b0999e48a50560e3a21b3be176041923a903a1669
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,203 @@ 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.
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
+ ## ⚠️ 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
74
267
 
75
268
  ## Development
76
269
 
@@ -80,7 +273,6 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
80
273
 
81
274
  ## Todo:
82
275
  * Create rake tasks to generate listener files.
83
- * RSpec tests for Railtie and integration tests.
84
276
 
85
277
  ## Contributing
86
278
 
data/Rakefile CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require "rubocop/rake_task"
8
+ require 'rubocop/rake_task'
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Listenable
4
4
  CALLBACK_MAP = {
5
- "created" => :after_create,
6
- "updated" => :after_update,
7
- "deleted" => :after_destroy
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
- @pending_hooks.concat(hooks.map(&:to_s))
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
@@ -2,39 +2,58 @@
2
2
 
3
3
  module Listenable
4
4
  class Railtie < Rails::Railtie
5
- initializer "listenable.load" do
5
+ initializer 'listenable.load' do
6
6
  Rails.application.config.to_prepare do
7
- # Load all listeners (supports nested paths)
8
- Dir[Rails.root.join("app/listeners/**/*.rb")].each { |f| require_dependency f }
7
+ # Load all listeners (recursive, supports namespaced)
8
+ Dir[Rails.root.join('app/listeners/**/*.rb')].each { |f| require_dependency f }
9
9
 
10
- # Wire models + listeners
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("Listener", "")
13
- model_class = model_class_name.safe_constantize
12
+ model_class_name = listener_class.name.sub('Listener', '')
13
+ model_class = model_class_name.safe_constantize
14
14
  next unless model_class
15
15
 
16
- listener_class.pending_hooks.each do |hook|
17
- action = hook.sub("on_", "")
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
- # Avoid duplicate subscriptions on reload
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 ActiveRecord callback
28
- model_class.send(callback) do
29
- ActiveSupport::Notifications.instrument(event, record: self)
29
+ injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
30
+ unless injected_events.include?(event)
31
+ model_class.send(callback) do
32
+ next unless Listenable.enabled
33
+
34
+ ActiveSupport::Notifications.instrument(event, record: self)
35
+ end
36
+ injected_events << event
37
+ model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
30
38
  end
31
39
 
32
- # Subscribe listener
33
40
  next unless listener_class.respond_to?(method)
34
41
 
35
42
  ActiveSupport::Notifications.subscribe(event) do |*args|
43
+ next unless Listenable.enabled
44
+
36
45
  _name, _start, _finish, _id, payload = args
37
- listener_class.public_send(method, payload[:record])
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)
56
+ end
38
57
  end
39
58
  end
40
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Listenable
4
- VERSION = "0.1.0"
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/listenable.rb CHANGED
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support"
4
- require "active_support/concern"
5
- require "active_support/notifications"
3
+ require 'active_support'
4
+ require 'active_support/concern'
5
+ require 'active_support/notifications'
6
6
 
7
- require_relative "listenable/version"
8
- require_relative "listenable/concern"
9
- require_relative "listenable/railtie" if defined?(Rails)
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
+ mattr_accessor :enabled, default: true
13
+
12
14
  class Error < StandardError; end
13
15
  # Your code goes here...
14
16
  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.1.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.