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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +5 -3
- data/lib/flu-rails/active_record_extender.rb +73 -11
- data/lib/flu-rails/configuration.rb +3 -1
- data/lib/flu-rails/errors.rb +3 -0
- data/lib/flu-rails/event_publisher.rb +42 -20
- data/lib/flu-rails/pending_publications.rb +67 -0
- data/lib/flu-rails/railtie.rb +6 -0
- data/lib/flu-rails/transaction_buffer.rb +61 -0
- data/lib/flu-rails/version.rb +1 -1
- data/lib/flu-rails.rb +34 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80a0fc1ea3123f0811eb936297c9aacc868e2b3f8e6d771a53dfd768901c6f83
|
|
4
|
+
data.tar.gz: 4764f353735e58b7e8a9183190fd104836caa15c7f99ed8df03a26bed5b21565
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
317
|
-
$ git push origin v8.0.
|
|
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,
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/flu-rails/errors.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
29
|
-
connected
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
67
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/flu-rails/railtie.rb
CHANGED
|
@@ -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
|
data/lib/flu-rails/version.rb
CHANGED
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.
|
|
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
|