flu-rails 8.0.5 → 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: 24d179705111c94185f7421297a692e1f962feeef2a9c2132a04e744d70a6503
4
- data.tar.gz: 93526441dcfaea7cc3a5be4795c5e86cdb9a4acdbaf95b825e530259b3c89d64
3
+ metadata.gz: bbe2d214138e198e66d6291a86e2614950e53f14c5556ee424cc98bd1a993981
4
+ data.tar.gz: c4884ce66ae50205a1a5033a3264da67dbd105d5f4e6210a6afbd9619161a1c8
5
5
  SHA512:
6
- metadata.gz: bd84b67b67c9f209fbab6c5309d719719e3d9f19df12e1d7816acc6690c084dad052e8590cc39b85cfcc9477b6c187d1ec352a3a8e55a932c5f51d22c062e458
7
- data.tar.gz: e7e0eab197a600c06a10c1bb6ec801499cfe3d55327e94232b565830b5ee6fa69c4a5bf7c19ab3082f0492917d83e6c36581ce53600d5cbf9ef0259df1b620b1
6
+ metadata.gz: fbd2777d9c2e08e2a46b2af5ed1baa357fa0bd16d75bf38b69352ef20318d4aa2b6ef75892a7e5438a2f31659e9fda808b0caef7897909e1c13928923e10c395
7
+ data.tar.gz: bb2e43abb4c22309616ca7c1bdca175922a89732cbd259978f634e9064e1f61a39bfc8b8a7007c0ba96f96b87dd573b1f5c71253ac1784921b0a97fa423d6eba
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ 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
+
24
+ ### [8.0.6] - 2026-08-19
25
+
26
+ **Fixed**
27
+
28
+ * Publish the events of a transaction from its commit rather than from each record's `after_commit`, which used to lose events two separate ways: Rails runs the transactional callbacks of a row on one single instance of it and skips the others (which one it picks is what `run_commit_callbacks_on_first_saved_instances_in_transaction` decides, and neither value was safe), and it skips every callback still queued as soon as one of them raises. Events now go out from the commit itself, which nothing can skip, once per recorded change and in the order the records were saved. A non-joinable transaction, such as the one `use_transactional_tests` wraps an example in, is never waited on, so a test suite sees what production does.
29
+ * Keep an event whose publication failed for another attempt rather than giving up on it there and then. It waits in memory, per thread, up to `max_pending_events`, and is published by the next transaction to commit on that thread or at the end of the request or the job, whichever comes first. Nothing is attempted while the publisher reports itself unreachable, and an event still refused after three attempts, or pushed out of a full buffer, is handed to `on_publication_failure`.
30
+ * Build and publish every change on its own, so an event that cannot be built or cannot reach the broker no longer costs the events queued behind it. A failed publication does not fail the transaction it belongs to either -- it has already committed by then.
31
+ * Reconnect to RabbitMQ after a fork. A forked child inherited the parent's `Bunny` connection and published on it, which the broker then ended for both. `EventPublisher` now remembers the pid it connected under, caches channels per process as well as per thread, and drops an inherited connection instead of closing it.
32
+ * Serialize `connect` and `disconnect` on a mutex. Two threads reaching `connect` together both opened a connection, the second overwriting the first, which stayed open and unreachable.
33
+
34
+ **Added**
35
+
36
+ * `Flu::ConnectionLostError`, raised by `publish` while the connection to RabbitMQ is down.
37
+ * `on_publication_failure`, called with the event and the error when an event cannot be published, so that an application can keep it rather than read about it in the logs. The event is `nil` when it could not even be built.
38
+ * `max_pending_events` (default `1000`), how many events a thread keeps waiting for the broker to be reachable again before the oldest are handed to `on_publication_failure`.
39
+
40
+ **Changed**
41
+
42
+ * Publishing while the connection is down now raises `Flu::ConnectionLostError` instead of the bare `RuntimeError` `Bunny::Session#create_channel` raises ("this connection is not open"), which nothing could tell apart from any other `RuntimeError`. It still fails immediately rather than waiting: Bunny reopens the connection in the background, and publishing works again once it has.
43
+
8
44
  ### [8.0.5] - 2026-08-03
9
45
 
10
46
  **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.5"
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
 
@@ -168,6 +169,9 @@ All options have a default value. However, all of them can be changed in your in
168
169
  | `default_ignored_request_params` | `[:password, :password_confirmation, :controller, :action]` | Boolean | Optional | By default, all these parameters will be ignored from controller request's `params` when creating an event. Independently of this option, any parameter your Rails application already masks through `config.filter_parameters` (passwords, tokens,...) is replaced with `"[FILTERED]"` in the event too, including inside nested params. | `false` |
169
170
  | `application_name` | `Rails.application.class.module_parent_name`, resolved on startup | String | Required | Is used as `emitter` for each event created by `flu-rails`, if not overriden by the `track_met`. | `my_app` |
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 }` |
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` |
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` |
171
175
 
172
176
  ## How to execute tests
173
177
 
@@ -313,8 +317,8 @@ scoped RubyGems credential.
313
317
  3. Tag the commit and push the tag:
314
318
 
315
319
  ```
316
- $ git tag -a v8.0.5 -m "Version 8.0.5"
317
- $ git push origin v8.0.5
320
+ $ git tag -a v8.0.8 -m "Version 8.0.8"
321
+ $ git push origin v8.0.8
318
322
  ```
319
323
 
320
324
  The workflow then checks that the tag matches `Flu::VERSION`, runs the tests, builds the gem
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/try"
4
+ require "active_support/notifications"
4
5
 
5
6
  module Flu
6
7
  class ActiveRecordExtender
7
8
  def self.extend_models(event_factory, event_publisher)
9
+ publish_on_transaction_commit
10
+
8
11
  ActiveRecord::Base.class_eval do
9
12
  unless singleton_class.method_defined?(:flu_is_tracked)
10
13
  class_attribute :flu_is_tracked, instance_accessor: false, default: false
11
14
  class_attribute :flu_user_metadata_lambdas, instance_accessor: false, default: {}.freeze
12
15
  class_attribute :flu_ignored_model_changes, instance_accessor: false, default: [].freeze
13
16
  class_attribute :flu_overriden_emitter_lambda, instance_accessor: false, default: nil
17
+ # Held per model rather than looked up on 'Flu', so that the publication driven by the
18
+ # transaction reaches the very publisher the model was tracked with.
19
+ class_attribute :flu_event_factory, instance_accessor: false, default: nil
20
+ class_attribute :flu_event_publisher, instance_accessor: false, default: nil
14
21
  end
15
22
 
16
23
  define_singleton_method(:track_entity_changes) do |options = {}|
@@ -18,6 +25,8 @@ module Flu
18
25
  self.flu_user_metadata_lambdas = options.fetch(:user_metadata, {})
19
26
  self.flu_ignored_model_changes = options.fetch(:ignored_model_changes, []).map(&:to_s)
20
27
  self.flu_overriden_emitter_lambda = options.fetch(:emitter, nil)
28
+ self.flu_event_factory = event_factory
29
+ self.flu_event_publisher = event_publisher
21
30
 
22
31
  after_create { flu_track_entity_change(:create, saved_changes, event_factory) }
23
32
  after_update { flu_track_entity_change(:update, saved_changes, event_factory) }
@@ -46,7 +55,8 @@ module Flu
46
55
  # and used 'run_callbacks(:commit)' instead, which runs *every* 'after_commit' callback on the record,
47
56
  # including the hoste application's own (mailers, jobs, cache invalidation), not just Flu's.
48
57
  define_method(:flu_publish_events!) do
49
- flu_commit_changes(event_factory, event_publisher)
58
+ flu_commit_changes(self.class.flu_event_factory || event_factory,
59
+ self.class.flu_event_publisher || event_publisher)
50
60
  end
51
61
 
52
62
  def flu_add_manual_event(name, data)
@@ -56,24 +66,37 @@ module Flu
56
66
  data: data,
57
67
  flu_is_a_manual_event: true
58
68
  })
69
+ Flu::TransactionBuffer.current.record(self) if self.class.flu_is_tracked
59
70
  end
60
71
 
61
72
  def flu_changes_as_events(event_factory)
62
- flu_changes.select do |data|
63
- !data[:changes].try(:empty?) || data[:flu_is_a_manual_event]
64
- end.map do |data|
65
- if data[:flu_is_a_manual_event]
66
- event_factory.build_manual_event(data[:name], data[:data])
67
- else
68
- event_factory.build_entity_change_event(data)
69
- end
73
+ flu_publishable_changes.map { |change| flu_change_as_event(change, event_factory) }
74
+ end
75
+
76
+ def flu_publishable_changes
77
+ flu_changes.select do |change|
78
+ !change[:changes].try(:empty?) || change[:flu_is_a_manual_event]
79
+ end
80
+ end
81
+
82
+ def flu_change_as_event(change, event_factory)
83
+ if change[:flu_is_a_manual_event]
84
+ event_factory.build_manual_event(change[:name], change[:data])
85
+ else
86
+ event_factory.build_entity_change_event(change)
70
87
  end
71
88
  end
72
89
 
90
+ # Every change is built and published on its own: one that cannot be is reported and the next
91
+ # one goes out all the same, where a raise would take the whole rest of the batch with it.
73
92
  def flu_commit_changes(event_factory, event_publisher)
74
- flu_changes_as_events(event_factory).each do |event|
93
+ flu_publishable_changes.each do |change|
94
+ event = flu_change_as_event(change, event_factory)
75
95
  event_publisher.publish(event)
96
+ rescue StandardError => error
97
+ Flu.publication_failed(event, error, event_publisher)
76
98
  end
99
+ ensure
77
100
  flu_flush_changes
78
101
  end
79
102
 
@@ -98,10 +121,49 @@ module Flu
98
121
  self.class.flu_association_columns,
99
122
  self.class.flu_ignored_model_changes,
100
123
  self.class.flu_overriden_emitter_lambda)
101
- flu_changes.push(data) unless data.nil?
124
+ return if data.nil?
125
+ flu_changes.push(data)
126
+ Flu::TransactionBuffer.current.record(self)
102
127
  end
103
128
  end
104
129
  end
105
130
  end
131
+
132
+ # The commit of the transaction, unlike the 'after_commit' callbacks that follow it, is a moment
133
+ # Rails cannot skip: the notification is emitted once the COMMIT is through and before the first
134
+ # callback runs, so no callback raising afterwards can cost anybody their events.
135
+ def self.publish_on_transaction_commit
136
+ unless @subscribed
137
+ @subscribed = true
138
+
139
+ ActiveSupport::Notifications.subscribe("start_transaction.active_record") do |*, payload|
140
+ TransactionBuffer.current.transaction_started if joinable?(payload)
141
+ end
142
+
143
+ ActiveSupport::Notifications.subscribe("transaction.active_record") do |*, payload|
144
+ next unless joinable?(payload)
145
+
146
+ buffer = TransactionBuffer.current
147
+ if payload[:outcome] == :commit
148
+ buffer.transaction_committed.each do |entity|
149
+ # Nothing may travel from here into the commit that is calling us.
150
+ entity.flu_commit_changes(entity.class.flu_event_factory, entity.class.flu_event_publisher)
151
+ rescue StandardError => error
152
+ Flu.config.logger&.error("Flu could not build the events of #{entity.class}: " \
153
+ "#{error.class}: #{error.message}")
154
+ end
155
+ Flu.retry_pending_publications
156
+ else
157
+ buffer.transaction_rolled_back.each(&:flu_rollback_changes)
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # A transaction opened as non-joinable, such as the one 'use_transactional_tests' wraps an
164
+ # example in, is rolled back rather than committed and only its savepoints publish anything.
165
+ def self.joinable?(payload)
166
+ !payload[:transaction].equal?(ActiveRecord::Transaction::NULL_TRANSACTION)
167
+ end
106
168
  end
107
169
  end
@@ -18,6 +18,9 @@ module Flu
18
18
  :default_ignored_model_changes,
19
19
  :default_ignored_request_params,
20
20
  :application_name,
21
- :bunny_options
21
+ :bunny_options,
22
+ :on_publication_failure,
23
+ :max_pending_events,
24
+ :max_connect_wait
22
25
  end
23
26
  end
@@ -6,4 +6,7 @@ module Flu
6
6
 
7
7
  class NotConnectedError < Error
8
8
  end
9
+
10
+ class ConnectionLostError < Error
11
+ end
9
12
  end
@@ -10,11 +10,18 @@ module Flu
10
10
  NOT_CONNECTED_MESSAGE = "no connection to RabbitMQ: 'connect' was never called, or " \
11
11
  "'disconnect' was. The railtie calls it at boot unless " \
12
12
  "'auto_connect_to_exchange' is false."
13
+ CONNECTION_LOST_MESSAGE = "the connection to RabbitMQ is down. Bunny reopens it in the " \
14
+ "background when 'automatically_recover' is on, and publishing " \
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
13
19
 
14
20
  def initialize(configuration)
15
- @logger = configuration.logger
16
- @configuration = configuration
17
- @exchange_key = :"flu_exchange_#{object_id}" # Channels are cached per thread
21
+ @logger = configuration.logger
22
+ @configuration = configuration
23
+ @mutex = Mutex.new
24
+ @next_attempt_at = 0
18
25
  end
19
26
 
20
27
  def publish(event, persistent=true)
@@ -22,54 +29,112 @@ module Flu
22
29
  @logger.debug { "Publishing event with id '#{event.id}' with routing key: #{routing_key}" }
23
30
  exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
24
31
  @logger.debug { "Event published." }
32
+ rescue Bunny::ConnectionClosedError
33
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE
25
34
  end
26
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.
27
39
  def connect
28
- unless connected?
29
- connected = false
30
- while !connected
31
- begin
32
- connect_to_exchange
33
- connected = true
34
- rescue Bunny::TCPConnectionFailedForAllHosts
35
- @logger.warn("RabbitMQ connection failed, try again in 1 second.")
36
- sleep 1
37
- end
40
+ @mutex.synchronize do
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
38
50
  end
39
51
  end
40
52
  end
41
53
 
42
54
  def connected?
43
- !@connection.nil? && @connection.open?
55
+ !forked? && !@connection.nil? && @connection.open?
44
56
  end
45
57
 
46
58
  # Closing the connection closes every channel opened on it, and stops the heartbeat and
47
59
  # recovery threads Bunny runs alongside it.
48
60
  # The guard is on the connection alone: a connection that was opened before the exchange could
49
61
  # be declared still has to be closed.
62
+ # An inherited connection is dropped rather than closed: its socket is the parent's.
50
63
  def disconnect
51
- if !@connection.nil? && @connection.open?
52
- @connection.close
64
+ @mutex.synchronize do
65
+ @connection.close if connected?
66
+ @connection = nil
67
+ @pid = nil
68
+ Thread.current[exchange_key] = nil
53
69
  end
54
- @connection = nil
55
- Thread.current[@exchange_key] = nil
56
70
  end
57
71
 
58
72
  private
59
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
+
113
+ # A child inherits the parent's socket but none of the threads Bunny runs on it.
114
+ def forked?
115
+ !@pid.nil? && @pid != Process.pid
116
+ end
117
+
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
+
60
123
  # One channel per thread rather than one for the whole publisher.
61
124
  # Bunny serialises every publication on the channel's own mutex.
62
125
  #
63
126
  # No bookkeeping of the channels handed out is needed.
64
127
  # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
65
128
  # 'disconnect' and a reconnection are both covered without reaching into other threads.
66
- # Only the absence of a connection is reported here. A connection that exists but is closed or
67
- # recovering is Bunny's story to tell, and its own error says more about it than this one could.
129
+ # A connection that is down is reported as such rather than left to 'create_channel', which
130
+ # raises a bare 'RuntimeError' the caller has no way to tell from any other.
68
131
  def exchange
69
- cached = Thread.current[@exchange_key]
132
+ reconnect if forked? || (abandoned? && due_for_another_attempt?)
133
+ cached = Thread.current[exchange_key]
70
134
  return cached if cached && cached.channel.open?
71
135
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
72
- Thread.current[@exchange_key] = declare_exchange
136
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
137
+ Thread.current[exchange_key] = declare_exchange
73
138
  end
74
139
 
75
140
  def declare_exchange
@@ -90,7 +155,8 @@ module Flu
90
155
 
91
156
  @connection = Bunny.new(options)
92
157
  @connection.start
93
- Thread.current[@exchange_key] = declare_exchange
158
+ @pid = Process.pid
159
+ Thread.current[exchange_key] = declare_exchange
94
160
  end
95
161
  end
96
162
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flu
4
+ # The events whose publication failed, kept for another attempt.
5
+ #
6
+ # A broker that closes a connection takes a few seconds to be usable again, since Bunny reopens it
7
+ # in the background, and an event published in that window has nowhere to go. Rather than being
8
+ # given up on there, it waits here for the connection to be back: the next transaction to commit on
9
+ # the thread comes back for it, and so does the end of the request or the job it belongs to.
10
+ #
11
+ # In memory, and per thread: what is waiting here dies with the process. An application that cannot
12
+ # afford to lose an event keeps it itself, from 'on_publication_failure', which is called for every
13
+ # event this gives up on.
14
+ class PendingPublications
15
+ MAX_ATTEMPTS = 3
16
+
17
+ def self.current
18
+ Thread.current[:flu_pending_publications] ||= new
19
+ end
20
+
21
+ def initialize
22
+ @entries = []
23
+ end
24
+
25
+ def size
26
+ @entries.size
27
+ end
28
+
29
+ def push(event, publisher, error)
30
+ give_up(@entries.shift) while @entries.size >= Flu.config.max_pending_events
31
+ @entries.push({ event: event, publisher: publisher, error: error, attempts: 1 })
32
+ end
33
+
34
+ # Publishes again what its publisher can reach again, keeps what it cannot, and gives up on what
35
+ # has been refused MAX_ATTEMPTS times, an event the broker itself rejects being no more publishable
36
+ # on the tenth attempt than on the first.
37
+ def drain
38
+ return if @entries.empty?
39
+
40
+ kept = []
41
+ @entries.each do |entry|
42
+ next kept.push(entry) unless reachable?(entry[:publisher])
43
+
44
+ begin
45
+ entry[:publisher].publish(entry[:event])
46
+ rescue StandardError => error
47
+ entry[:error] = error
48
+ entry[:attempts] += 1
49
+ entry[:attempts] < MAX_ATTEMPTS ? kept.push(entry) : give_up(entry)
50
+ end
51
+ end
52
+ @entries = kept
53
+ end
54
+
55
+ private
56
+
57
+ def reachable?(publisher)
58
+ publisher.connected?
59
+ rescue StandardError
60
+ false
61
+ end
62
+
63
+ def give_up(entry)
64
+ Flu.report_publication_failure(entry[:event], entry[:error])
65
+ end
66
+ end
67
+ end
@@ -15,5 +15,11 @@ module Flu
15
15
  Flu.init
16
16
  Flu.start
17
17
  end
18
+
19
+ # The end of a request or of a job, which runs even when it is an exception that ended it, is the
20
+ # last chance to publish what the broker could not take a moment earlier.
21
+ initializer "flu.retry_pending_publications" do |application|
22
+ application.executor.to_complete { Flu.retry_pending_publications }
23
+ end
18
24
  end
19
25
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flu
4
+ # The entities that recorded events in the transaction currently open, so that its commit can
5
+ # publish them.
6
+ #
7
+ # A record hands its events over from 'after_commit', and Rails skips every transactional callback
8
+ # still to run as soon as one of them raises: it re-commits the rest of the batch with
9
+ # 'should_run_callbacks: false' (ActiveRecord::ConnectionAdapters::Transaction#commit_records). One
10
+ # raising callback, in this gem or in the application, therefore drops the events of every record
11
+ # left in the queue. The commit of the transaction itself is the one moment nothing can skip, and
12
+ # it is where these are published instead.
13
+ #
14
+ # One buffer per thread, one mark per open transaction: what a transaction recorded is what was
15
+ # pushed past its mark, which is what its rollback discards and what the outermost commit hands
16
+ # back. A thread holding transactions open on several databases at once shares that one stack, so
17
+ # the events of the first to commit wait for the last.
18
+ class TransactionBuffer
19
+ def self.current
20
+ Thread.current[:flu_transaction_buffer] ||= new
21
+ end
22
+
23
+ def initialize
24
+ @entities = []
25
+ @marks = []
26
+ end
27
+
28
+ # @return [Boolean] false when no transaction is open, in which case nothing will ever drain the
29
+ # buffer and the entity is left to publish its own changes.
30
+ def record(entity)
31
+ if @marks.empty?
32
+ false
33
+ else
34
+ @entities.push(entity)
35
+ true
36
+ end
37
+ end
38
+
39
+ def transaction_started
40
+ @marks.push(@entities.size)
41
+ end
42
+
43
+ # @return [Array] the entities to publish: those of the outermost transaction, none otherwise,
44
+ # a nested transaction leaving what it recorded to the one it is nested in.
45
+ def transaction_committed
46
+ @marks.pop
47
+ if @marks.empty?
48
+ committed = @entities
49
+ @entities = []
50
+ committed
51
+ else
52
+ []
53
+ end
54
+ end
55
+
56
+ # @return [Array] the entities whose events the rollback discards.
57
+ def transaction_rolled_back
58
+ @entities.slice!((@marks.pop || 0)..) || []
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flu
4
- VERSION = "8.0.5"
4
+ VERSION = "8.0.8"
5
5
  end
data/lib/flu-rails.rb CHANGED
@@ -10,6 +10,8 @@ require_relative "flu-rails/event_factory"
10
10
  require_relative "flu-rails/queue_repository"
11
11
  require_relative "flu-rails/configuration"
12
12
  require_relative "flu-rails/core_ext"
13
+ require_relative "flu-rails/transaction_buffer"
14
+ require_relative "flu-rails/pending_publications"
13
15
  require_relative "flu-rails/event_publisher"
14
16
  require_relative "flu-rails/util"
15
17
  require_relative "flu-rails/dummy/in_memory_event_publisher"
@@ -36,15 +38,41 @@ module Flu
36
38
  @event_publisher
37
39
  end
38
40
 
41
+ # Keeps an event whose publication failed for another attempt, a broker being reachable again
42
+ # within seconds. One that could not even be built is reported at once instead.
43
+ def self.publication_failed(event, error, event_publisher)
44
+ if event.nil?
45
+ report_publication_failure(event, error)
46
+ else
47
+ PendingPublications.current.push(event, event_publisher, error)
48
+ end
49
+ end
50
+
51
+ # Publishes again what the last attempt could not. Never raises: its callers are a transaction that
52
+ # has committed and the end of a request, neither of which is a place to fail.
53
+ def self.retry_pending_publications
54
+ PendingPublications.current.drain
55
+ rescue StandardError => error
56
+ config.logger&.error("Flu could not retry the publications it had kept: #{error.class}: #{error.message}")
57
+ end
58
+
59
+ # @param event [Flu::Event, nil] nil when the event could not even be built.
60
+ def self.report_publication_failure(event, error)
61
+ handler = config.on_publication_failure
62
+ if handler.nil?
63
+ subject = event.nil? ? "an event it could not build" : "the event '#{event.id}' ('#{event.name}')"
64
+ config.logger&.error("Flu could not publish #{subject}: #{error.class}: #{error.message}. " \
65
+ "The event is lost unless 'on_publication_failure' is configured to keep it.")
66
+ else
67
+ handler.call(event, error)
68
+ end
69
+ end
70
+
39
71
  def self.init
40
72
  @configuration.application_name ||= default_application_name
41
73
  raise "configuration.application_name must not be nil" if @configuration.application_name.nil?
42
74
  @logger = @configuration.logger
43
75
  @event_factory = Flu::EventFactory.new(@configuration)
44
- # The railtie re-runs 'init' on every code reload. The publisher being replaced owns an open
45
- # connection, its channel and Bunny's heartbeat thread: dropping the reference to it without
46
- # closing it leaks all three for the lifetime of the process.
47
- @event_publisher&.disconnect
48
76
  @event_publisher = create_event_publisher(@configuration)
49
77
  extend_models_and_controllers
50
78
  end
@@ -58,14 +86,24 @@ module Flu
58
86
  end
59
87
  end
60
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.
61
94
  def self.create_event_publisher(configuration)
62
- 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
63
100
  logger.info("Loading Flu with a dummy event publisher (this will not connect any exchange)")
64
- require_relative "flu-rails/dummy/in_memory_event_publisher"
65
- Flu::Dummy::InMemoryEventPublisher.new(@configuration)
66
- else
67
- Flu::EventPublisher.new(@configuration)
68
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
69
107
  end
70
108
 
71
109
  def self.is_testing_environment?
@@ -80,8 +118,13 @@ module Flu
80
118
  Flu::CoreExt.extend_controller_classes(@event_factory, @event_publisher, @logger)
81
119
  end
82
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.
83
123
  def self.start
84
- @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}")
85
128
  end
86
129
 
87
130
  def self.load_configuration
@@ -103,6 +146,9 @@ module Flu
103
146
  config.default_ignored_request_params = [:password, :password_confirmation, :controller, :action]
104
147
  config.application_name = nil
105
148
  config.bunny_options = {}
149
+ config.on_publication_failure = nil
150
+ config.max_pending_events = 1000
151
+ config.max_connect_wait = 30
106
152
  end
107
153
  end
108
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.5
4
+ version: 8.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Loïc Vigneron
@@ -203,8 +203,10 @@ files:
203
203
  - lib/flu-rails/event.rb
204
204
  - lib/flu-rails/event_factory.rb
205
205
  - lib/flu-rails/event_publisher.rb
206
+ - lib/flu-rails/pending_publications.rb
206
207
  - lib/flu-rails/queue_repository.rb
207
208
  - lib/flu-rails/railtie.rb
209
+ - lib/flu-rails/transaction_buffer.rb
208
210
  - lib/flu-rails/util.rb
209
211
  - lib/flu-rails/version.rb
210
212
  homepage: https://github.com/crepesourcing/flu-rails