flu-rails 8.0.6 → 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: 80a0fc1ea3123f0811eb936297c9aacc868e2b3f8e6d771a53dfd768901c6f83
4
- data.tar.gz: 4764f353735e58b7e8a9183190fd104836caa15c7f99ed8df03a26bed5b21565
3
+ metadata.gz: bbc282f411276d4ad52ca02b624367edafd40b6396eafe57f6c4250c9c18da29
4
+ data.tar.gz: 28fcf8776ec3aa50318bfbed9d7779482be0f76cd3e51222c965c7ace9acb316
5
5
  SHA512:
6
- metadata.gz: cfdf59bb4782cad462e48110c90084506141bb4d5b16ee39db055feafc5f925eb3cf705bd772038d1fb67bc4c5f9e40841d5a82959362c3c999adad3951d7175
7
- data.tar.gz: 0f888571495893ff2e503b10133364bb5fe556aaf0f48105ab6a4c106b11234cbc5fbb5c59002224542674f73976da593b535d5e90a3485a3558e3fc95e9f9d1
6
+ metadata.gz: 6085eec4b84cb2a42eee67eebc5eb6752c21a8f1c889d59cb3ce215fa2e91efd7f13b31d816662fb96b3dc8ab30b79b20bf6cb44049ff964277793d2a71ea571
7
+ data.tar.gz: f6ef03cd47e961a3234e21e5d51bce950da55326e5df21d305c8080be3e31eb7356339b546b68daec73c05ef83ea72752526860d553cbe4ece04474e57c0c4a7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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
+
18
+ ### [8.0.8] - 2026-08-26
19
+
20
+ **Fixed**
21
+
22
+ * Keep the event publisher across code reloads. The railtie re-runs `init` on every reload, and `init` disconnected the publisher it replaced -- but a tracked model publishes through the very publisher it was tracked with, so every model the reload did not reload kept publishing on a connection that had just been closed, and nothing ever reopened it: `Flu::NotConnectedError` ("'connect' was never called, or 'disconnect' was") on every event from then on, until the process was restarted. The publisher now outlives the reloads, and only a change of kind (the real publisher for the dummy one, or the other way round) replaces and disconnects it.
23
+ * Reopen a connection Bunny has stopped recovering. Bunny reopens a connection it lost, but not one that never opened, nor one whose recovery attempts ran out: `publish` only ever reconnected after a fork, so a publisher left with such a connection raised on every event for the rest of the process. It now reopens it on the next publication, and no more than once every five seconds, since a broker that hangs rather than refuses costs a full `connect_timeout` per attempt.
24
+
25
+ **Changed**
26
+
27
+ * `connect` no longer retries an unreachable broker forever. It gives up after `max_connect_wait` seconds and raises `Flu::ConnectionLostError`. The railtie calls it from `to_prepare`, which runs on every code reload: waiting on a broker that never answers held the reload interlock, and the request behind it, for good.
28
+ * A broker that cannot be reached at startup no longer keeps the application from booting. `Flu.start` logs the failure, and publishing opens the connection once the broker answers again.
29
+
30
+ **Added**
31
+
32
+ * `max_connect_wait` (default `30`), how many seconds the startup retries a broker that does not answer before letting the application boot. `nil` waits for the broker for as long as it takes.
33
+
8
34
  ### [8.0.6] - 2026-08-19
9
35
 
10
36
  **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.6"
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`)
@@ -50,7 +50,11 @@ Each configuration is detailed below.
50
50
  ### Start up
51
51
 
52
52
  `flu-rails` starts automatically through its `Railtie`: there is nothing to call by hand.
53
- Its startup waits until its RabbitMQ exchange is connected.
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
+ publishing reopens the connection itself once the broker answers again.
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.
54
58
 
55
59
  ### Track changes on an ActiveRecord model
56
60
 
@@ -170,6 +174,7 @@ All options have a default value. However, all of them can be changed in your in
170
174
  | `bunny_options` | `{}` | Hash of symbols | Optional | Additional options to add when connecting the RabbitMQ broker. This overrides the existing options with the same name. | `{ verify_peer: true }` |
171
175
  | `max_pending_events` | `1000` | Integer | Optional | An event the broker refused waits for the connection to be back, and is published by the next commit of the thread or at the end of the request. This is how many a thread keeps waiting before handing the oldest to `on_publication_failure`. They are held in memory: a process that dies takes them with it. | `5000` |
172
176
  | `on_publication_failure` | `nil` | Lambda | Optional | Called with the event and the error when an event cannot be published, instead of logging it. The transaction the event belongs to has already committed by then, so this is the last chance to keep it: store it and publish it again later. The event is `nil` when it could not even be built. | `lambda { |event, error| OutboxEvent.create!(payload: event&.to_json, error: error.message) }` |
177
+ | `max_connect_wait` | `30` | Integer or `nil` | Optional | How many seconds the startup retries a broker that does not answer before giving up and letting the application boot. Publishing reopens the connection itself once the broker answers again. `nil` waits for the broker for as long as it takes. | `nil` |
173
178
 
174
179
  ## How to execute tests
175
180
 
@@ -315,8 +320,8 @@ scoped RubyGems credential.
315
320
  3. Tag the commit and push the tag:
316
321
 
317
322
  ```
318
- $ git tag -a v8.0.6 -m "Version 8.0.6"
319
- $ git push origin v8.0.6
323
+ $ git tag -a v8.0.9 -m "Version 8.0.9"
324
+ $ git push origin v8.0.9
320
325
  ```
321
326
 
322
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
 
@@ -20,6 +20,7 @@ module Flu
20
20
  :application_name,
21
21
  :bunny_options,
22
22
  :on_publication_failure,
23
- :max_pending_events
23
+ :max_pending_events,
24
+ :max_connect_wait
24
25
  end
25
26
  end
@@ -13,11 +13,17 @@ module Flu
13
13
  CONNECTION_LOST_MESSAGE = "the connection to RabbitMQ is down. Bunny reopens it in the " \
14
14
  "background when 'automatically_recover' is on, and publishing " \
15
15
  "works again once it has."
16
+ CONNECTION_FAILED_MESSAGE = "could not reach RabbitMQ within %s seconds. Publishing reopens " \
17
+ "the connection itself once the broker answers again."
18
+ RECONNECTION_INTERVAL = 5
16
19
 
17
20
  def initialize(configuration)
18
- @logger = configuration.logger
19
- @configuration = configuration
20
- @mutex = Mutex.new
21
+ @logger = configuration.logger
22
+ @configuration = configuration
23
+ @mutex = Mutex.new
24
+ @next_attempt_at = 0
25
+ @exchanges = {}
26
+ @exchanges_mutex = Mutex.new
21
27
  end
22
28
 
23
29
  def publish(event, persistent=true)
@@ -29,19 +35,20 @@ module Flu
29
35
  raise ConnectionLostError, CONNECTION_LOST_MESSAGE
30
36
  end
31
37
 
38
+ # Retries a broker that is not there yet, for at most 'max_connect_wait' seconds. Waiting on it
39
+ # forever would hold whatever called it -- the railtie calls it from 'to_prepare', which runs on
40
+ # every code reload, holding the reload interlock and the request that triggered it.
32
41
  def connect
33
42
  @mutex.synchronize do
34
- unless connected?
35
- connected = false
36
- while !connected
37
- begin
38
- connect_to_exchange
39
- connected = true
40
- rescue Bunny::TCPConnectionFailedForAllHosts
41
- @logger.warn("RabbitMQ connection failed, try again in 1 second.")
42
- sleep 1
43
- end
44
- end
43
+ next if connected?
44
+ give_up_at = deadline
45
+ begin
46
+ connect_to_exchange
47
+ rescue Bunny::TCPConnectionFailedForAllHosts
48
+ raise ConnectionLostError, format(CONNECTION_FAILED_MESSAGE, @configuration.max_connect_wait) if expired?(give_up_at)
49
+ @logger.warn("RabbitMQ connection failed, try again in 1 second.")
50
+ sleep 1
51
+ retry
45
52
  end
46
53
  end
47
54
  end
@@ -60,37 +67,130 @@ module Flu
60
67
  @connection.close if connected?
61
68
  @connection = nil
62
69
  @pid = nil
63
- Thread.current[exchange_key] = nil
70
+ forget_exchanges
64
71
  end
65
72
  end
66
73
 
67
74
  private
68
75
 
76
+ def now
77
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ end
79
+
80
+ # A nil 'max_connect_wait' waits on the broker for however long it takes.
81
+ def deadline
82
+ @configuration.max_connect_wait.nil? ? nil : now + @configuration.max_connect_wait
83
+ end
84
+
85
+ def expired?(give_up_at)
86
+ !give_up_at.nil? && now >= give_up_at
87
+ end
88
+
89
+ # A connection Bunny is recovering comes back on its own. One that never opened, or that Bunny
90
+ # has given up on, comes back from here or not at all.
91
+ def abandoned?
92
+ return false if @connection.nil? || @connection.open?
93
+ !@connection.recovering_from_network_failure? &&
94
+ (!@connection.automatically_recover? ||
95
+ @connection.closed? ||
96
+ @connection.status == :not_connected)
97
+ end
98
+
99
+ # A broker that hangs rather than refuses costs a full Bunny 'connect_timeout' per attempt, so
100
+ # publishing pays for one at most every 'RECONNECTION_INTERVAL' seconds.
101
+ def due_for_another_attempt?
102
+ return false if now < @next_attempt_at
103
+ @next_attempt_at = now + RECONNECTION_INTERVAL
104
+ true
105
+ end
106
+
107
+ # Reopening is best effort: the caller is publishing, and an event that cannot go out is
108
+ # reported through the connection errors below rather than through whatever the broker refused.
109
+ def reconnect
110
+ @mutex.synchronize { connect_to_exchange unless connected? }
111
+ rescue StandardError => error
112
+ @logger.warn("Could not reopen the connection to RabbitMQ: #{error.class}: #{error.message}")
113
+ end
114
+
69
115
  # A child inherits the parent's socket but none of the threads Bunny runs on it.
70
116
  def forked?
71
117
  !@pid.nil? && @pid != Process.pid
72
118
  end
73
119
 
74
- # Per process too: a channel cached before the fork still reports itself open in the child.
75
- def exchange_key
76
- :"flu_exchange_#{object_id}_#{Process.pid}"
77
- end
78
-
79
- # One channel per thread rather than one for the whole publisher.
80
- # 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.
127
+ #
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.
81
130
  #
82
- # No bookkeeping of the channels handed out is needed.
83
- # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
84
- # 'disconnect' and a reconnection are both covered without reaching into other threads.
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.
85
133
  # A connection that is down is reported as such rather than left to 'create_channel', which
86
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.
87
138
  def exchange
88
- connect if forked?
89
- cached = Thread.current[exchange_key]
90
- return cached if cached && cached.channel.open?
139
+ reconnect if forked? || (abandoned? && due_for_another_attempt?)
91
140
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
92
141
  raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
93
- 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}" }
94
194
  end
95
195
 
96
196
  def declare_exchange
@@ -109,10 +209,14 @@ module Flu
109
209
  automatically_recover: true
110
210
  }.merge(@configuration.bunny_options || {})
111
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
112
215
  @connection = Bunny.new(options)
216
+ hold_publishing_while_bunny_reopens(@connection, options)
113
217
  @connection.start
114
218
  @pid = Process.pid
115
- Thread.current[exchange_key] = declare_exchange
219
+ remember_exchange(declare_exchange)
116
220
  end
117
221
  end
118
222
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flu
4
- VERSION = "8.0.6"
4
+ VERSION = "8.0.9"
5
5
  end
data/lib/flu-rails.rb CHANGED
@@ -73,10 +73,6 @@ module Flu
73
73
  raise "configuration.application_name must not be nil" if @configuration.application_name.nil?
74
74
  @logger = @configuration.logger
75
75
  @event_factory = Flu::EventFactory.new(@configuration)
76
- # The railtie re-runs 'init' on every code reload. The publisher being replaced owns an open
77
- # connection, its channel and Bunny's heartbeat thread: dropping the reference to it without
78
- # closing it leaks all three for the lifetime of the process.
79
- @event_publisher&.disconnect
80
76
  @event_publisher = create_event_publisher(@configuration)
81
77
  extend_models_and_controllers
82
78
  end
@@ -90,14 +86,24 @@ module Flu
90
86
  end
91
87
  end
92
88
 
89
+ # The railtie re-runs 'init' on every code reload. Building a new publisher on each of them left
90
+ # every reference the application had already taken on the previous one -- a tracked model
91
+ # publishes through the very publisher it was tracked with -- on a connection 'init' had closed
92
+ # and that nothing ever reopens. The publisher outlives the reloads, and only a change of kind
93
+ # replaces it: its connection, its channels and Bunny's heartbeat thread are then closed with it.
93
94
  def self.create_event_publisher(configuration)
94
- if is_testing_environment?
95
+ publisher_class = event_publisher_class
96
+ return @event_publisher if @event_publisher.instance_of?(publisher_class)
97
+
98
+ @event_publisher&.disconnect
99
+ if publisher_class == Flu::Dummy::InMemoryEventPublisher
95
100
  logger.info("Loading Flu with a dummy event publisher (this will not connect any exchange)")
96
- require_relative "flu-rails/dummy/in_memory_event_publisher"
97
- Flu::Dummy::InMemoryEventPublisher.new(@configuration)
98
- else
99
- Flu::EventPublisher.new(@configuration)
100
101
  end
102
+ publisher_class.new(configuration)
103
+ end
104
+
105
+ def self.event_publisher_class
106
+ is_testing_environment? ? Flu::Dummy::InMemoryEventPublisher : Flu::EventPublisher
101
107
  end
102
108
 
103
109
  def self.is_testing_environment?
@@ -112,8 +118,13 @@ module Flu
112
118
  Flu::CoreExt.extend_controller_classes(@event_factory, @event_publisher, @logger)
113
119
  end
114
120
 
121
+ # A broker that is not there yet must not keep the application from booting: publishing reopens
122
+ # the connection itself once the broker answers again.
115
123
  def self.start
116
- @event_publisher.connect if config.auto_connect_to_exchange
124
+ return unless config.auto_connect_to_exchange
125
+ @event_publisher.connect
126
+ rescue Flu::ConnectionLostError => error
127
+ config.logger&.error("Flu could not connect to RabbitMQ: #{error.message}")
117
128
  end
118
129
 
119
130
  def self.load_configuration
@@ -137,6 +148,7 @@ module Flu
137
148
  config.bunny_options = {}
138
149
  config.on_publication_failure = nil
139
150
  config.max_pending_events = 1000
151
+ config.max_connect_wait = 30
140
152
  end
141
153
  end
142
154
 
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.6
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