listenable 0.3.0 → 0.3.1
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/lib/listenable/concern.rb +6 -3
- data/lib/listenable/railtie.rb +58 -43
- data/lib/listenable/version.rb +1 -1
- data/lib/listenable.rb +28 -3
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8ebfdc90196162ce2a340f960b6c7f0f1de287c4806cf230bded917eee5bd3f
|
|
4
|
+
data.tar.gz: 8435bcb681df67ae94a66225e5f95765145cb81dfbed4b4f02362682fceadff4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80f93245b2d340672b0c9178f637f3a127230a4348e08b3099f27d2ff43cdd9b8baa77cdf6bf8492ef446c5e55666c3f2b4a16e1b073b1ac33bdd7439f5f1491
|
|
7
|
+
data.tar.gz: 69681153c86034c8a4c8563478cf6c580b97409566bc50d87537c477106d040fa6837e29b578a0b39ae37f23dbf90a9f4fdab23d7a743a4153afb65f59ee46e4
|
data/lib/listenable/concern.rb
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Listenable
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
4
6
|
CALLBACK_MAP = {
|
|
5
7
|
'created' => :after_create,
|
|
6
8
|
'updated' => :after_update,
|
|
7
9
|
'deleted' => :after_destroy
|
|
8
10
|
}.freeze
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
included do
|
|
13
|
+
# Register this class when Listenable is included
|
|
14
|
+
Listenable.register_listener(self)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
class_methods do
|
|
15
18
|
def listen(*hooks, async: false)
|
|
16
19
|
@pending_hooks ||= []
|
|
17
20
|
|
data/lib/listenable/railtie.rb
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Listenable
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
+
AFTER_COMMIT_MAP = {
|
|
6
|
+
'created' => :create,
|
|
7
|
+
'updated' => :update,
|
|
8
|
+
'destroyed' => :destroy
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
5
11
|
# Cleanup on Rails reload to prevent memory leaks
|
|
6
12
|
config.to_prepare do
|
|
7
13
|
Listenable.cleanup!
|
|
@@ -17,57 +23,72 @@ module Listenable
|
|
|
17
23
|
initializer 'listenable.load' do
|
|
18
24
|
Rails.application.config.to_prepare do
|
|
19
25
|
# Load all listeners (recursive, supports namespaced)
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
Dir[Rails.root.join('app/listeners/**/*.rb')].each do |file|
|
|
27
|
+
require_dependency file
|
|
28
|
+
end
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
listener_classes = ObjectSpace.each_object(Class).select { |klass| klass < Listenable }
|
|
25
|
-
listener_classes.each do |listener_class|
|
|
30
|
+
Listenable.listener_classes.each do |listener_class|
|
|
26
31
|
model_class_name = listener_class.name.sub('Listener', '')
|
|
27
32
|
model_class = model_class_name.safe_constantize
|
|
28
33
|
next unless model_class
|
|
29
34
|
|
|
35
|
+
injected_events =
|
|
36
|
+
model_class.instance_variable_get(:@_listenable_injected_events) || []
|
|
37
|
+
|
|
30
38
|
listener_class.pending_hooks.each do |hook_info|
|
|
31
39
|
hook = hook_info[:name]
|
|
32
40
|
async = hook_info[:async]
|
|
33
41
|
action = hook.sub('on_', '')
|
|
34
|
-
callback = Listenable::CALLBACK_MAP[action] or next
|
|
35
42
|
method = "on_#{action}"
|
|
36
43
|
event = "#{model_class_name.underscore}.#{action}"
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
|
|
40
|
-
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
41
|
-
end
|
|
45
|
+
next unless listener_class.respond_to?(method)
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
# Inject callback once per model
|
|
44
48
|
unless injected_events.include?(event)
|
|
45
|
-
|
|
49
|
+
commit_action = AFTER_COMMIT_MAP[action]
|
|
50
|
+
next unless commit_action
|
|
51
|
+
|
|
52
|
+
model_class.after_commit(on: commit_action) do
|
|
46
53
|
next unless Listenable.enabled
|
|
47
54
|
|
|
48
|
-
ActiveSupport::Notifications.instrument(
|
|
55
|
+
ActiveSupport::Notifications.instrument(
|
|
56
|
+
event,
|
|
57
|
+
record_class: self.class,
|
|
58
|
+
record_id: id
|
|
59
|
+
)
|
|
49
60
|
end
|
|
61
|
+
|
|
50
62
|
injected_events << event
|
|
51
|
-
model_class.instance_variable_set(
|
|
63
|
+
model_class.instance_variable_set(
|
|
64
|
+
:@_listenable_injected_events,
|
|
65
|
+
injected_events
|
|
66
|
+
)
|
|
52
67
|
end
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Subscribe and track subscriber for cleanup
|
|
69
|
+
# Subscribe (only once per reload)
|
|
57
70
|
subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
|
|
58
71
|
next unless Listenable.enabled
|
|
59
72
|
|
|
60
73
|
_name, _start, _finish, _id, payload = args
|
|
61
|
-
record = payload[:record]
|
|
62
74
|
|
|
63
75
|
if async
|
|
64
|
-
Railtie.handle_async_listener(
|
|
76
|
+
Railtie.handle_async_listener(
|
|
77
|
+
listener_class,
|
|
78
|
+
method,
|
|
79
|
+
payload[:record_class],
|
|
80
|
+
payload[:record_id]
|
|
81
|
+
)
|
|
65
82
|
else
|
|
66
|
-
Railtie.handle_sync_listener(
|
|
83
|
+
Railtie.handle_sync_listener(
|
|
84
|
+
listener_class,
|
|
85
|
+
method,
|
|
86
|
+
payload[:record_class],
|
|
87
|
+
payload[:record_id]
|
|
88
|
+
)
|
|
67
89
|
end
|
|
68
90
|
end
|
|
69
91
|
|
|
70
|
-
# Track subscriber for cleanup on reload
|
|
71
92
|
Listenable.subscribers << subscriber
|
|
72
93
|
end
|
|
73
94
|
end
|
|
@@ -75,52 +96,46 @@ module Listenable
|
|
|
75
96
|
end
|
|
76
97
|
|
|
77
98
|
class << self
|
|
78
|
-
|
|
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
|
|
99
|
+
def handle_async_listener(listener_class, method, record_class, record_id)
|
|
85
100
|
Concurrent::Promises.future_on(Listenable.async_executor) do
|
|
86
|
-
# Wrap in connection pool management to prevent connection exhaustion
|
|
87
101
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
88
102
|
execute_listener(listener_class, method, record_class, record_id)
|
|
89
|
-
rescue StandardError => e
|
|
90
|
-
log_error(listener_class, method, e)
|
|
91
103
|
end
|
|
92
|
-
|
|
104
|
+
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
93
105
|
Rails.logger&.error(
|
|
94
|
-
"[Listenable]
|
|
106
|
+
"[Listenable] DB pool exhausted for #{listener_class}##{method}: #{e.message}"
|
|
95
107
|
)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
log_error(listener_class, method, e)
|
|
96
110
|
end
|
|
97
111
|
end
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
listener_class.public_send(method, record)
|
|
113
|
+
def handle_sync_listener(listener_class, method, record_class, record_id)
|
|
114
|
+
execute_listener(listener_class, method, record_class, record_id)
|
|
102
115
|
rescue StandardError => e
|
|
103
116
|
log_error(listener_class, method, e)
|
|
104
|
-
raise
|
|
117
|
+
raise
|
|
105
118
|
end
|
|
106
119
|
|
|
107
120
|
private
|
|
108
121
|
|
|
109
122
|
def execute_listener(listener_class, method, record_class, record_id)
|
|
110
|
-
|
|
123
|
+
record = record_class.find_by(id: record_id)
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
listener_class.public_send(method, reloaded_record)
|
|
114
|
-
else
|
|
125
|
+
unless record
|
|
115
126
|
Rails.logger&.warn(
|
|
116
|
-
"[Listenable]
|
|
127
|
+
"[Listenable] #{record_class}##{record_id} not found for #{listener_class}##{method}"
|
|
117
128
|
)
|
|
129
|
+
return
|
|
118
130
|
end
|
|
131
|
+
|
|
132
|
+
listener_class.public_send(method, record)
|
|
119
133
|
end
|
|
120
134
|
|
|
121
135
|
def log_error(listener_class, method, error)
|
|
122
136
|
Rails.logger&.error(
|
|
123
|
-
"[Listenable] #{listener_class}##{method} failed:
|
|
137
|
+
"[Listenable] #{listener_class}##{method} failed: " \
|
|
138
|
+
"#{error.message}\n#{error.backtrace.first(5).join("\n")}"
|
|
124
139
|
)
|
|
125
140
|
end
|
|
126
141
|
end
|
data/lib/listenable/version.rb
CHANGED
data/lib/listenable.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'active_support'
|
|
|
4
4
|
require 'active_support/concern'
|
|
5
5
|
require 'active_support/notifications'
|
|
6
6
|
require 'concurrent'
|
|
7
|
+
require 'set'
|
|
7
8
|
|
|
8
9
|
require_relative 'listenable/version'
|
|
9
10
|
require_relative 'listenable/concern'
|
|
@@ -12,24 +13,45 @@ require_relative 'listenable/railtie' if defined?(Rails)
|
|
|
12
13
|
module Listenable
|
|
13
14
|
mattr_accessor :enabled, default: true
|
|
14
15
|
|
|
16
|
+
# Connection pool safety configuration
|
|
17
|
+
mattr_accessor :connection_checkout_timeout, default: 5 # seconds
|
|
18
|
+
mattr_accessor :max_thread_pool_ratio, default: 0.25 # 25% of connection pool
|
|
19
|
+
mattr_accessor :max_thread_pool_size, default: 3 # absolute max threads
|
|
20
|
+
|
|
15
21
|
class Error < StandardError; end
|
|
22
|
+
class ConnectionPoolExhausted < Error; end
|
|
16
23
|
|
|
17
24
|
class << self
|
|
18
25
|
attr_writer :async_executor
|
|
19
26
|
|
|
27
|
+
# Registry of all listener classes (better than ObjectSpace scan)
|
|
28
|
+
def listener_classes
|
|
29
|
+
@listener_classes ||= Set.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register a listener class when Listenable is included
|
|
33
|
+
def register_listener(klass)
|
|
34
|
+
listener_classes.add(klass) if klass.name && !klass.name.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Clear registry on reload
|
|
38
|
+
def clear_listeners!
|
|
39
|
+
@listener_classes = Set.new
|
|
40
|
+
end
|
|
41
|
+
|
|
20
42
|
# Track active subscribers to prevent memory leaks on reload
|
|
21
43
|
def subscribers
|
|
22
44
|
@subscribers ||= []
|
|
23
45
|
end
|
|
24
46
|
|
|
25
47
|
# Calculate a safe thread pool size based on connection pool
|
|
26
|
-
# Very conservative: use
|
|
48
|
+
# Very conservative: use configurable ratio of pool (default 25%)
|
|
27
49
|
def default_thread_pool_size
|
|
28
50
|
return 2 unless defined?(ActiveRecord::Base)
|
|
29
51
|
|
|
30
52
|
pool_size = ActiveRecord::Base.connection_pool.size
|
|
31
|
-
# Use
|
|
32
|
-
[[pool_size
|
|
53
|
+
# Use configured ratio of pool, but at least 1 thread, max configured limit
|
|
54
|
+
[[pool_size * max_thread_pool_ratio, 1].max, max_thread_pool_size].min.to_i
|
|
33
55
|
end
|
|
34
56
|
|
|
35
57
|
# Thread pool executor for async listeners
|
|
@@ -55,6 +77,9 @@ module Listenable
|
|
|
55
77
|
end
|
|
56
78
|
@subscribers = []
|
|
57
79
|
|
|
80
|
+
# Clear listener registry for fresh reload
|
|
81
|
+
clear_listeners!
|
|
82
|
+
|
|
58
83
|
# Shutdown thread pool gracefully
|
|
59
84
|
shutdown_async_executor!
|
|
60
85
|
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.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Den Meralpis
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '6.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ostruct
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: rails
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -118,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
118
132
|
- !ruby/object:Gem::Version
|
|
119
133
|
version: '0'
|
|
120
134
|
requirements: []
|
|
121
|
-
rubygems_version:
|
|
135
|
+
rubygems_version: 4.0.3
|
|
122
136
|
specification_version: 4
|
|
123
137
|
summary: A Rails DSL for model event listeners using ActiveSupport::Notifications.
|
|
124
138
|
test_files: []
|