flu-rails 8.0.8 → 8.0.10

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: 9245f314def815225067e1198b65862370df7b9c5a23ed9aaa4650325123a2d1
4
+ data.tar.gz: 11ec07a03357de79a783674d6782d1034b3c879d32a4f7f2321a07d9b2b88420
5
5
  SHA512:
6
- metadata.gz: fbd2777d9c2e08e2a46b2af5ed1baa357fa0bd16d75bf38b69352ef20318d4aa2b6ef75892a7e5438a2f31659e9fda808b0caef7897909e1c13928923e10c395
7
- data.tar.gz: bb2e43abb4c22309616ca7c1bdca175922a89732cbd259978f634e9064e1f61a39bfc8b8a7007c0ba96f96b87dd573b1f5c71253ac1784921b0a97fa423d6eba
6
+ metadata.gz: '08c9fa68a5162a63d1b2c3949f21675a64d59a513661e98672d1b4f35211e9b5295ef6086c13168bb18ddbe13e4e21b822800e303ad6672af9f0aa4b1c2e57d9'
7
+ data.tar.gz: 1707f100cf25d4d77d7462e11c6d9484cb6e5f0089f06d56e42b9a54a5b24f90e3cf2e75061090dc4ef5e5203c96eb51fb8993846bcd4a4d9e859e6f78e3c0bb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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.10] - 2026-09-15
9
+
10
+ **Fixed**
11
+
12
+ * Keep a pending publication waiting while Bunny reopens the channels, rather than spend its attempts. Since 8.0.9, publishing while Bunny reopens the channels raises `Flu::ConnectionLostError`, and the event is kept for another attempt. But the publisher still said it was `connected?` in that window -- the connection is `open?` from its handshake on -- so every drain of the pending publications retried the event at once and charged it an attempt: one right after the failing commit, another at the next commit on the thread or at the end of the job. An event failing there had one attempt left out of three, and a thread committing twice within the few milliseconds Bunny takes to reopen the channels lost it for a transaction that had committed. `connected?` is now false while Bunny reopens the channels, as it is while the connection is down, and a pending publication waits for Bunny to be done.
13
+
14
+ ### [8.0.9] - 2026-09-14
15
+
16
+ * Enforce gem `json < 3`
17
+
18
+ **Fixed**
19
+
20
+ * 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.
21
+ * 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.
22
+ * 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.
23
+
8
24
  ### [8.0.8] - 2026-08-26
9
25
 
10
26
  **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.10"
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.10 -m "Version 8.0.10"
324
+ $ git push origin v8.0.10
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)
@@ -51,8 +53,15 @@ module Flu
51
53
  end
52
54
  end
53
55
 
56
+ # Not connected while Bunny is reopening the channels either, although the connection is 'open?'
57
+ # from its handshake on: 'exchange' refuses to publish in that window, and 'PendingPublications'
58
+ # asks here before it retries. A publisher that said it was connected and then refused cost the
59
+ # event one of its attempts on every drain -- the one right after the failing commit, then the
60
+ # next commit or the end of the job -- and a thread committing twice within the few milliseconds
61
+ # Bunny takes to reopen the channels lost the event for a committed transaction. Saying it is not
62
+ # connected makes the event wait for Bunny to be done, as it does while the connection is down.
54
63
  def connected?
55
- !forked? && !@connection.nil? && @connection.open?
64
+ !forked? && !@connection.nil? && @connection.open? && !being_reopened?
56
65
  end
57
66
 
58
67
  # Closing the connection closes every channel opened on it, and stops the heartbeat and
@@ -65,7 +74,7 @@ module Flu
65
74
  @connection.close if connected?
66
75
  @connection = nil
67
76
  @pid = nil
68
- Thread.current[exchange_key] = nil
77
+ forget_exchanges
69
78
  end
70
79
  end
71
80
 
@@ -115,26 +124,80 @@ module Flu
115
124
  !@pid.nil? && @pid != Process.pid
116
125
  end
117
126
 
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.
127
+ # One channel per thread, kept in '@exchanges' (thread => exchange).
128
+ #
129
+ # Why not 'Thread.current[]': it is per fiber, not per thread, and a channel stored there is
130
+ # never closed when the thread ends. RabbitMQ allows 2047 channels per connection, so a server
131
+ # that keeps creating and ending threads hit that limit after a day and could not publish
132
+ # from any new thread. Now, each time a channel is opened, the channels of the threads that
133
+ # have ended are closed.
125
134
  #
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.
135
+ # Until then, '@exchanges' still references the ended threads and what
136
+ # their thread-local variables hold: a weak reference would lose the channel before it is closed.
137
+ #
138
+ # A thread whose channel is closed opens a new one on its next publication.
139
+ # That is what makes 'disconnect' and a reconnection safe.
129
140
  # A connection that is down is reported as such rather than left to 'create_channel', which
130
141
  # raises a bare 'RuntimeError' the caller has no way to tell from any other.
142
+ # The connection is checked before the cached channel: Bunny marks the channels open before it
143
+ # announces it is done, so a thread that read its channel closed, then the announcement, would
144
+ # open one more.
131
145
  def exchange
132
146
  reconnect if forked? || (abandoned? && due_for_another_attempt?)
133
- cached = Thread.current[exchange_key]
134
- return cached if cached && cached.channel.open?
135
147
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
136
148
  raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
137
- Thread.current[exchange_key] = declare_exchange
149
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE if being_reopened?
150
+ cached = @exchanges_mutex.synchronize { @exchanges[Thread.current] }
151
+ return cached if cached && cached.channel.open?
152
+ remember_exchange(declare_exchange)
153
+ end
154
+
155
+ # Bunny reopens the connection first, then the channels it had on it. A channel opened in between
156
+ # stays open on the broker with no thread to use it, and the session does not show that window:
157
+ # 'open?' is true as soon as the socket is. Bunny announces each recovery attempt and its
158
+ # completion instead. The session is kept rather than a flag: a child process inherits the flag
159
+ # of a connection its parent was reopening, and opens one of its own.
160
+ # Bunny holds one callback of each: the ones the application gave in 'bunny_options' are called
161
+ # from here.
162
+ def hold_publishing_while_bunny_reopens(session, options)
163
+ started = options[:recovery_attempt_started]
164
+ completed = options[:recovery_completed]
165
+ session.before_recovery_attempt_starts { @being_reopened = session; started&.call }
166
+ session.after_recovery_completed { @being_reopened = nil if @being_reopened.equal?(session); completed&.call }
167
+ end
168
+
169
+ def being_reopened?
170
+ @being_reopened&.equal?(@connection)
171
+ end
172
+
173
+ def remember_exchange(exchange)
174
+ ended = @exchanges_mutex.synchronize do
175
+ @exchanges[Thread.current] = exchange
176
+ @exchanges.keys.reject(&:alive?).map { |thread| @exchanges.delete(thread) }
177
+ end
178
+
179
+ unless ended.empty?
180
+ @logger.debug { "Closing the channels of #{ended.size} threads that have ended." }
181
+ end
182
+
183
+ ended.each { |orphan| close_channel(orphan.channel) }
184
+ exchange
185
+ end
186
+
187
+ # Forgets the channels without closing them.
188
+ # Their connection is gone: closed by 'disconnect',
189
+ # lost, or inherited from a parent process
190
+ # (closing that one would close the parent's socket).
191
+ def forget_exchanges
192
+ @exchanges_mutex.synchronize { @exchanges.clear }
193
+ end
194
+
195
+ # Closing may fail (the broker may have closed the channel already). That is fine: the thread
196
+ # is gone, and the publication in progress must not fail because of it.
197
+ def close_channel(channel)
198
+ channel.close if channel.open?
199
+ rescue StandardError => error
200
+ @logger.debug { "Could not close the channel of an ended thread: #{error.class}: #{error.message}" }
138
201
  end
139
202
 
140
203
  def declare_exchange
@@ -153,10 +216,14 @@ module Flu
153
216
  automatically_recover: true
154
217
  }.merge(@configuration.bunny_options || {})
155
218
 
219
+ # Before 'start': when it fails, the channels of the previous connection must be gone already,
220
+ # or the forking thread would publish on the parent's channel in a child that has no broker.
221
+ forget_exchanges
156
222
  @connection = Bunny.new(options)
223
+ hold_publishing_while_bunny_reopens(@connection, options)
157
224
  @connection.start
158
225
  @pid = Process.pid
159
- Thread.current[exchange_key] = declare_exchange
226
+ remember_exchange(declare_exchange)
160
227
  end
161
228
  end
162
229
  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.10"
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.10
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