flu-rails 8.0.6 → 8.0.8

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: bbe2d214138e198e66d6291a86e2614950e53f14c5556ee424cc98bd1a993981
4
+ data.tar.gz: c4884ce66ae50205a1a5033a3264da67dbd105d5f4e6210a6afbd9619161a1c8
5
5
  SHA512:
6
- metadata.gz: cfdf59bb4782cad462e48110c90084506141bb4d5b16ee39db055feafc5f925eb3cf705bd772038d1fb67bc4c5f9e40841d5a82959362c3c999adad3951d7175
7
- data.tar.gz: 0f888571495893ff2e503b10133364bb5fe556aaf0f48105ab6a4c106b11234cbc5fbb5c59002224542674f73976da593b535d5e90a3485a3558e3fc95e9f9d1
6
+ metadata.gz: fbd2777d9c2e08e2a46b2af5ed1baa357fa0bd16d75bf38b69352ef20318d4aa2b6ef75892a7e5438a2f31659e9fda808b0caef7897909e1c13928923e10c395
7
+ data.tar.gz: bb2e43abb4c22309616ca7c1bdca175922a89732cbd259978f634e9064e1f61a39bfc8b8a7007c0ba96f96b87dd573b1f5c71253ac1784921b0a97fa423d6eba
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.8] - 2026-08-26
9
+
10
+ **Fixed**
11
+
12
+ * 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.
13
+ * 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.
14
+
15
+ **Changed**
16
+
17
+ * `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.
18
+ * 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.
19
+
20
+ **Added**
21
+
22
+ * `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.
23
+
8
24
  ### [8.0.6] - 2026-08-19
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.6"
19
+ gem "flu-rails", "8.0.8"
20
20
  ```
21
21
 
22
22
  Then, create an initializer into your Rails app (`config/initializers/flu-rails.rb`)
@@ -50,7 +50,8 @@ 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.
54
55
 
55
56
  ### Track changes on an ActiveRecord model
56
57
 
@@ -170,6 +171,7 @@ All options have a default value. However, all of them can be changed in your in
170
171
  | `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
172
  | `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
173
  | `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) }` |
174
+ | `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
175
 
174
176
  ## How to execute tests
175
177
 
@@ -315,8 +317,8 @@ scoped RubyGems credential.
315
317
  3. Tag the commit and push the tag:
316
318
 
317
319
  ```
318
- $ git tag -a v8.0.6 -m "Version 8.0.6"
319
- $ git push origin v8.0.6
320
+ $ git tag -a v8.0.8 -m "Version 8.0.8"
321
+ $ git push origin v8.0.8
320
322
  ```
321
323
 
322
324
  The workflow then checks that the tag matches `Flu::VERSION`, runs the tests, builds the gem
@@ -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,15 @@ 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
21
25
  end
22
26
 
23
27
  def publish(event, persistent=true)
@@ -29,19 +33,20 @@ module Flu
29
33
  raise ConnectionLostError, CONNECTION_LOST_MESSAGE
30
34
  end
31
35
 
36
+ # Retries a broker that is not there yet, for at most 'max_connect_wait' seconds. Waiting on it
37
+ # forever would hold whatever called it -- the railtie calls it from 'to_prepare', which runs on
38
+ # every code reload, holding the reload interlock and the request that triggered it.
32
39
  def connect
33
40
  @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
41
+ next if connected?
42
+ give_up_at = deadline
43
+ begin
44
+ connect_to_exchange
45
+ rescue Bunny::TCPConnectionFailedForAllHosts
46
+ raise ConnectionLostError, format(CONNECTION_FAILED_MESSAGE, @configuration.max_connect_wait) if expired?(give_up_at)
47
+ @logger.warn("RabbitMQ connection failed, try again in 1 second.")
48
+ sleep 1
49
+ retry
45
50
  end
46
51
  end
47
52
  end
@@ -66,6 +71,45 @@ module Flu
66
71
 
67
72
  private
68
73
 
74
+ def now
75
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ end
77
+
78
+ # A nil 'max_connect_wait' waits on the broker for however long it takes.
79
+ def deadline
80
+ @configuration.max_connect_wait.nil? ? nil : now + @configuration.max_connect_wait
81
+ end
82
+
83
+ def expired?(give_up_at)
84
+ !give_up_at.nil? && now >= give_up_at
85
+ end
86
+
87
+ # A connection Bunny is recovering comes back on its own. One that never opened, or that Bunny
88
+ # has given up on, comes back from here or not at all.
89
+ def abandoned?
90
+ return false if @connection.nil? || @connection.open?
91
+ !@connection.recovering_from_network_failure? &&
92
+ (!@connection.automatically_recover? ||
93
+ @connection.closed? ||
94
+ @connection.status == :not_connected)
95
+ end
96
+
97
+ # A broker that hangs rather than refuses costs a full Bunny 'connect_timeout' per attempt, so
98
+ # publishing pays for one at most every 'RECONNECTION_INTERVAL' seconds.
99
+ def due_for_another_attempt?
100
+ return false if now < @next_attempt_at
101
+ @next_attempt_at = now + RECONNECTION_INTERVAL
102
+ true
103
+ end
104
+
105
+ # Reopening is best effort: the caller is publishing, and an event that cannot go out is
106
+ # reported through the connection errors below rather than through whatever the broker refused.
107
+ def reconnect
108
+ @mutex.synchronize { connect_to_exchange unless connected? }
109
+ rescue StandardError => error
110
+ @logger.warn("Could not reopen the connection to RabbitMQ: #{error.class}: #{error.message}")
111
+ end
112
+
69
113
  # A child inherits the parent's socket but none of the threads Bunny runs on it.
70
114
  def forked?
71
115
  !@pid.nil? && @pid != Process.pid
@@ -85,7 +129,7 @@ module Flu
85
129
  # A connection that is down is reported as such rather than left to 'create_channel', which
86
130
  # raises a bare 'RuntimeError' the caller has no way to tell from any other.
87
131
  def exchange
88
- connect if forked?
132
+ reconnect if forked? || (abandoned? && due_for_another_attempt?)
89
133
  cached = Thread.current[exchange_key]
90
134
  return cached if cached && cached.channel.open?
91
135
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
@@ -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.8"
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.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Loïc Vigneron