flu-rails 8.0.8 → 8.0.9

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: bbe2d214138e198e66d6291a86e2614950e53f14c5556ee424cc98bd1a993981
4
- data.tar.gz: c4884ce66ae50205a1a5033a3264da67dbd105d5f4e6210a6afbd9619161a1c8
3
+ metadata.gz: bbc282f411276d4ad52ca02b624367edafd40b6396eafe57f6c4250c9c18da29
4
+ data.tar.gz: 28fcf8776ec3aa50318bfbed9d7779482be0f76cd3e51222c965c7ace9acb316
5
5
  SHA512:
6
- metadata.gz: fbd2777d9c2e08e2a46b2af5ed1baa357fa0bd16d75bf38b69352ef20318d4aa2b6ef75892a7e5438a2f31659e9fda808b0caef7897909e1c13928923e10c395
7
- data.tar.gz: bb2e43abb4c22309616ca7c1bdca175922a89732cbd259978f634e9064e1f61a39bfc8b8a7007c0ba96f96b87dd573b1f5c71253ac1784921b0a97fa423d6eba
6
+ metadata.gz: 6085eec4b84cb2a42eee67eebc5eb6752c21a8f1c889d59cb3ce215fa2e91efd7f13b31d816662fb96b3dc8ab30b79b20bf6cb44049ff964277793d2a71ea571
7
+ data.tar.gz: f6ef03cd47e961a3234e21e5d51bce950da55326e5df21d305c8080be3e31eb7356339b546b68daec73c05ef83ea72752526860d553cbe4ece04474e57c0c4a7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ### [8.0.9] - 2026-09-14
9
+
10
+ * Enforce gem `json < 3`
11
+
12
+ **Fixed**
13
+
14
+ * Close the channel of a thread that has ended. Since 8.0.5, each thread publishes on its own Bunny channel, and that channel was never closed when the thread ended. RabbitMQ allows 2047 channels per connection, so a server that keeps creating and ending threads (Puma does, when `min_threads` is lower than `max_threads`) reached that limit after about a day. From then on, every new thread failed to publish with `RuntimeError: Cannot open a channel: max number of channels on connection reached`, while the older threads kept publishing normally, which made the problem look random. The channels of the threads that have ended are now closed each time a new channel is opened.
15
+ * Publish from a fiber on the channel of its thread. The channel was stored in `Thread.current[]`, which is per fiber, not per thread: a publication made from an `Enumerator` or a streaming response opened one more channel, and never closed it either.
16
+ * Publish on the channel Bunny reopens rather than on a new one. After a lost connection, Bunny reopens the connection first, then the channels it had on it. A thread that published in between opened one more channel, or hung for 15 seconds when Bunny was still in the handshake, and the one Bunny was reopening stayed open on the broker with no thread to use it. Publishing in between now raises `Flu::ConnectionLostError`, as it does while the connection is down, and goes on with the reopened channel once Bunny is done.
17
+
8
18
  ### [8.0.8] - 2026-08-26
9
19
 
10
20
  **Fixed**
data/README.md CHANGED
@@ -16,7 +16,7 @@ For now, events are generated from:
16
16
  Add the gem to your project's Gemfile:
17
17
 
18
18
  ```ruby
19
- gem "flu-rails", "8.0.8"
19
+ gem "flu-rails", "8.0.9"
20
20
  ```
21
21
 
22
22
  Then, create an initializer into your Rails app (`config/initializers/flu-rails.rb`)
@@ -53,6 +53,9 @@ Each configuration is detailed below.
53
53
  Its startup waits for its RabbitMQ exchange to be connected, for at most `max_connect_wait` seconds. A broker that is still not there by then does not keep the application from booting:
54
54
  publishing reopens the connection itself once the broker answers again.
55
55
 
56
+ It opens one connection per process, and one channel on it per thread that publishes. A fiber publishes on the channel of its thread.
57
+ The channels of the threads that have ended are closed the next time a thread opens one, so a server that keeps creating and ending threads (Puma does, when `min_threads` is lower than `max_threads`) does not accumulate them.
58
+
56
59
  ### Track changes on an ActiveRecord model
57
60
 
58
61
  All subclasses of `ActiveRecord::Base` that call `track_entity_changes` are "tracked". _E.g._:
@@ -317,8 +320,8 @@ scoped RubyGems credential.
317
320
  3. Tag the commit and push the tag:
318
321
 
319
322
  ```
320
- $ git tag -a v8.0.8 -m "Version 8.0.8"
321
- $ git push origin v8.0.8
323
+ $ git tag -a v8.0.9 -m "Version 8.0.9"
324
+ $ git push origin v8.0.9
322
325
  ```
323
326
 
324
327
  The workflow then checks that the tag matches `Flu::VERSION`, runs the tests, builds the gem
data/flu-rails.gemspec CHANGED
@@ -28,6 +28,9 @@ Gem::Specification.new do |spec|
28
28
  spec.add_dependency "activerecord", "~> 8.0"
29
29
  spec.add_dependency "activesupport", "~> 8.0"
30
30
  spec.add_dependency "bunny", "~> 3.1"
31
+ # json 3 refuses the options Faraday 2.14.3 still passes to 'JSON.parse' as a hash.
32
+ # Fixed on Faraday's main branch, not released yet.
33
+ spec.add_dependency "json", "< 3"
31
34
  spec.add_dependency "logger", "~> 1.7"
32
35
  spec.add_dependency "rabbitmq_http_api_client", "~> 3.2"
33
36
 
@@ -22,6 +22,8 @@ module Flu
22
22
  @configuration = configuration
23
23
  @mutex = Mutex.new
24
24
  @next_attempt_at = 0
25
+ @exchanges = {}
26
+ @exchanges_mutex = Mutex.new
25
27
  end
26
28
 
27
29
  def publish(event, persistent=true)
@@ -65,7 +67,7 @@ module Flu
65
67
  @connection.close if connected?
66
68
  @connection = nil
67
69
  @pid = nil
68
- Thread.current[exchange_key] = nil
70
+ forget_exchanges
69
71
  end
70
72
  end
71
73
 
@@ -115,26 +117,80 @@ module Flu
115
117
  !@pid.nil? && @pid != Process.pid
116
118
  end
117
119
 
118
- # Per process too: a channel cached before the fork still reports itself open in the child.
119
- def exchange_key
120
- :"flu_exchange_#{object_id}_#{Process.pid}"
121
- end
122
-
123
- # One channel per thread rather than one for the whole publisher.
124
- # Bunny serialises every publication on the channel's own mutex.
120
+ # One channel per thread, kept in '@exchanges' (thread => exchange).
121
+ #
122
+ # Why not 'Thread.current[]': it is per fiber, not per thread, and a channel stored there is
123
+ # never closed when the thread ends. RabbitMQ allows 2047 channels per connection, so a server
124
+ # that keeps creating and ending threads hit that limit after a day and could not publish
125
+ # from any new thread. Now, each time a channel is opened, the channels of the threads that
126
+ # have ended are closed.
125
127
  #
126
- # No bookkeeping of the channels handed out is needed.
127
- # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
128
- # 'disconnect' and a reconnection are both covered without reaching into other threads.
128
+ # Until then, '@exchanges' still references the ended threads and what
129
+ # their thread-local variables hold: a weak reference would lose the channel before it is closed.
130
+ #
131
+ # A thread whose channel is closed opens a new one on its next publication.
132
+ # That is what makes 'disconnect' and a reconnection safe.
129
133
  # A connection that is down is reported as such rather than left to 'create_channel', which
130
134
  # raises a bare 'RuntimeError' the caller has no way to tell from any other.
135
+ # The connection is checked before the cached channel: Bunny marks the channels open before it
136
+ # announces it is done, so a thread that read its channel closed, then the announcement, would
137
+ # open one more.
131
138
  def exchange
132
139
  reconnect if forked? || (abandoned? && due_for_another_attempt?)
133
- cached = Thread.current[exchange_key]
134
- return cached if cached && cached.channel.open?
135
140
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
136
141
  raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
137
- Thread.current[exchange_key] = declare_exchange
142
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE if being_reopened?
143
+ cached = @exchanges_mutex.synchronize { @exchanges[Thread.current] }
144
+ return cached if cached && cached.channel.open?
145
+ remember_exchange(declare_exchange)
146
+ end
147
+
148
+ # Bunny reopens the connection first, then the channels it had on it. A channel opened in between
149
+ # stays open on the broker with no thread to use it, and the session does not show that window:
150
+ # 'open?' is true as soon as the socket is. Bunny announces each recovery attempt and its
151
+ # completion instead. The session is kept rather than a flag: a child process inherits the flag
152
+ # of a connection its parent was reopening, and opens one of its own.
153
+ # Bunny holds one callback of each: the ones the application gave in 'bunny_options' are called
154
+ # from here.
155
+ def hold_publishing_while_bunny_reopens(session, options)
156
+ started = options[:recovery_attempt_started]
157
+ completed = options[:recovery_completed]
158
+ session.before_recovery_attempt_starts { @being_reopened = session; started&.call }
159
+ session.after_recovery_completed { @being_reopened = nil if @being_reopened.equal?(session); completed&.call }
160
+ end
161
+
162
+ def being_reopened?
163
+ @being_reopened&.equal?(@connection)
164
+ end
165
+
166
+ def remember_exchange(exchange)
167
+ ended = @exchanges_mutex.synchronize do
168
+ @exchanges[Thread.current] = exchange
169
+ @exchanges.keys.reject(&:alive?).map { |thread| @exchanges.delete(thread) }
170
+ end
171
+
172
+ unless ended.empty?
173
+ @logger.debug { "Closing the channels of #{ended.size} threads that have ended." }
174
+ end
175
+
176
+ ended.each { |orphan| close_channel(orphan.channel) }
177
+ exchange
178
+ end
179
+
180
+ # Forgets the channels without closing them.
181
+ # Their connection is gone: closed by 'disconnect',
182
+ # lost, or inherited from a parent process
183
+ # (closing that one would close the parent's socket).
184
+ def forget_exchanges
185
+ @exchanges_mutex.synchronize { @exchanges.clear }
186
+ end
187
+
188
+ # Closing may fail (the broker may have closed the channel already). That is fine: the thread
189
+ # is gone, and the publication in progress must not fail because of it.
190
+ def close_channel(channel)
191
+ channel.close if channel.open?
192
+ rescue StandardError => error
193
+ @logger.debug { "Could not close the channel of an ended thread: #{error.class}: #{error.message}" }
138
194
  end
139
195
 
140
196
  def declare_exchange
@@ -153,10 +209,14 @@ module Flu
153
209
  automatically_recover: true
154
210
  }.merge(@configuration.bunny_options || {})
155
211
 
212
+ # Before 'start': when it fails, the channels of the previous connection must be gone already,
213
+ # or the forking thread would publish on the parent's channel in a child that has no broker.
214
+ forget_exchanges
156
215
  @connection = Bunny.new(options)
216
+ hold_publishing_while_bunny_reopens(@connection, options)
157
217
  @connection.start
158
218
  @pid = Process.pid
159
- Thread.current[exchange_key] = declare_exchange
219
+ remember_exchange(declare_exchange)
160
220
  end
161
221
  end
162
222
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flu
4
- VERSION = "8.0.8"
4
+ VERSION = "8.0.9"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flu-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.8
4
+ version: 8.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Loïc Vigneron
@@ -68,6 +68,20 @@ dependencies:
68
68
  - - "~>"
69
69
  - !ruby/object:Gem::Version
70
70
  version: '3.1'
71
+ - !ruby/object:Gem::Dependency
72
+ name: json
73
+ requirement: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '3'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - "<"
83
+ - !ruby/object:Gem::Version
84
+ version: '3'
71
85
  - !ruby/object:Gem::Dependency
72
86
  name: logger
73
87
  requirement: !ruby/object:Gem::Requirement