flu-rails 8.0.5 → 8.0.6

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: 80a0fc1ea3123f0811eb936297c9aacc868e2b3f8e6d771a53dfd768901c6f83
4
+ data.tar.gz: 4764f353735e58b7e8a9183190fd104836caa15c7f99ed8df03a26bed5b21565
5
5
  SHA512:
6
- metadata.gz: bd84b67b67c9f209fbab6c5309d719719e3d9f19df12e1d7816acc6690c084dad052e8590cc39b85cfcc9477b6c187d1ec352a3a8e55a932c5f51d22c062e458
7
- data.tar.gz: e7e0eab197a600c06a10c1bb6ec801499cfe3d55327e94232b565830b5ee6fa69c4a5bf7c19ab3082f0492917d83e6c36581ce53600d5cbf9ef0259df1b620b1
6
+ metadata.gz: cfdf59bb4782cad462e48110c90084506141bb4d5b16ee39db055feafc5f925eb3cf705bd772038d1fb67bc4c5f9e40841d5a82959362c3c999adad3951d7175
7
+ data.tar.gz: 0f888571495893ff2e503b10133364bb5fe556aaf0f48105ab6a4c106b11234cbc5fbb5c59002224542674f73976da593b535d5e90a3485a3558e3fc95e9f9d1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ 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.6] - 2026-08-19
9
+
10
+ **Fixed**
11
+
12
+ * 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.
13
+ * 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`.
14
+ * 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.
15
+ * 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.
16
+ * 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.
17
+
18
+ **Added**
19
+
20
+ * `Flu::ConnectionLostError`, raised by `publish` while the connection to RabbitMQ is down.
21
+ * `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.
22
+ * `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`.
23
+
24
+ **Changed**
25
+
26
+ * 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.
27
+
8
28
  ### [8.0.5] - 2026-08-03
9
29
 
10
30
  **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.6"
20
20
  ```
21
21
 
22
22
  Then, create an initializer into your Rails app (`config/initializers/flu-rails.rb`)
@@ -168,6 +168,8 @@ All options have a default value. However, all of them can be changed in your in
168
168
  | `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
169
  | `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
170
  | `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
+ | `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
+ | `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) }` |
171
173
 
172
174
  ## How to execute tests
173
175
 
@@ -313,8 +315,8 @@ scoped RubyGems credential.
313
315
  3. Tag the commit and push the tag:
314
316
 
315
317
  ```
316
- $ git tag -a v8.0.5 -m "Version 8.0.5"
317
- $ git push origin v8.0.5
318
+ $ git tag -a v8.0.6 -m "Version 8.0.6"
319
+ $ git push origin v8.0.6
318
320
  ```
319
321
 
320
322
  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,8 @@ 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
22
24
  end
23
25
  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,14 @@ 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."
13
16
 
14
17
  def initialize(configuration)
15
18
  @logger = configuration.logger
16
19
  @configuration = configuration
17
- @exchange_key = :"flu_exchange_#{object_id}" # Channels are cached per thread
20
+ @mutex = Mutex.new
18
21
  end
19
22
 
20
23
  def publish(event, persistent=true)
@@ -22,54 +25,72 @@ module Flu
22
25
  @logger.debug { "Publishing event with id '#{event.id}' with routing key: #{routing_key}" }
23
26
  exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
24
27
  @logger.debug { "Event published." }
28
+ rescue Bunny::ConnectionClosedError
29
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE
25
30
  end
26
31
 
27
32
  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
33
+ @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
37
44
  end
38
45
  end
39
46
  end
40
47
  end
41
48
 
42
49
  def connected?
43
- !@connection.nil? && @connection.open?
50
+ !forked? && !@connection.nil? && @connection.open?
44
51
  end
45
52
 
46
53
  # Closing the connection closes every channel opened on it, and stops the heartbeat and
47
54
  # recovery threads Bunny runs alongside it.
48
55
  # The guard is on the connection alone: a connection that was opened before the exchange could
49
56
  # be declared still has to be closed.
57
+ # An inherited connection is dropped rather than closed: its socket is the parent's.
50
58
  def disconnect
51
- if !@connection.nil? && @connection.open?
52
- @connection.close
59
+ @mutex.synchronize do
60
+ @connection.close if connected?
61
+ @connection = nil
62
+ @pid = nil
63
+ Thread.current[exchange_key] = nil
53
64
  end
54
- @connection = nil
55
- Thread.current[@exchange_key] = nil
56
65
  end
57
66
 
58
67
  private
59
68
 
69
+ # A child inherits the parent's socket but none of the threads Bunny runs on it.
70
+ def forked?
71
+ !@pid.nil? && @pid != Process.pid
72
+ end
73
+
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
+
60
79
  # One channel per thread rather than one for the whole publisher.
61
80
  # Bunny serialises every publication on the channel's own mutex.
62
81
  #
63
82
  # No bookkeeping of the channels handed out is needed.
64
83
  # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
65
84
  # '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.
85
+ # A connection that is down is reported as such rather than left to 'create_channel', which
86
+ # raises a bare 'RuntimeError' the caller has no way to tell from any other.
68
87
  def exchange
69
- cached = Thread.current[@exchange_key]
88
+ connect if forked?
89
+ cached = Thread.current[exchange_key]
70
90
  return cached if cached && cached.channel.open?
71
91
  raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
72
- Thread.current[@exchange_key] = declare_exchange
92
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
93
+ Thread.current[exchange_key] = declare_exchange
73
94
  end
74
95
 
75
96
  def declare_exchange
@@ -90,7 +111,8 @@ module Flu
90
111
 
91
112
  @connection = Bunny.new(options)
92
113
  @connection.start
93
- Thread.current[@exchange_key] = declare_exchange
114
+ @pid = Process.pid
115
+ Thread.current[exchange_key] = declare_exchange
94
116
  end
95
117
  end
96
118
  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.6"
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,6 +38,36 @@ 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?
@@ -103,6 +135,8 @@ module Flu
103
135
  config.default_ignored_request_params = [:password, :password_confirmation, :controller, :action]
104
136
  config.application_name = nil
105
137
  config.bunny_options = {}
138
+ config.on_publication_failure = nil
139
+ config.max_pending_events = 1000
106
140
  end
107
141
  end
108
142
 
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.6
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