flu-rails 8.0.4 → 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: 5fadf301a677ec0ec47d67f9eb8159aac7dfab535c268030331ce4d54c391e58
4
- data.tar.gz: 90c8b9dc6c71fa9f01630b1e183114132ec1d63735cdc2cb62c5974750f3ff49
3
+ metadata.gz: 80a0fc1ea3123f0811eb936297c9aacc868e2b3f8e6d771a53dfd768901c6f83
4
+ data.tar.gz: 4764f353735e58b7e8a9183190fd104836caa15c7f99ed8df03a26bed5b21565
5
5
  SHA512:
6
- metadata.gz: 2a8f50d7eb2796ce8408f3a1ca36b68d55020d8858134fc136cef7be6fcb7978c316e99298a2d394ef0f0555602c1a955b3ff497b0a72e9b1f49ddf23ec4e1b8
7
- data.tar.gz: dbc5b57979a4ebe601b8df0c26822c7dfa127c44f7292b97b654aff0363d04266552a60c2bda0d0ea00b2865421e47677d4f66592bf8c9e36bf6820f1ea290e8
6
+ metadata.gz: cfdf59bb4782cad462e48110c90084506141bb4d5b16ee39db055feafc5f925eb3cf705bd772038d1fb67bc4c5f9e40841d5a82959362c3c999adad3951d7175
7
+ data.tar.gz: 0f888571495893ff2e503b10133364bb5fe556aaf0f48105ab6a4c106b11234cbc5fbb5c59002224542674f73976da593b535d5e90a3485a3558e3fc95e9f9d1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ 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
+
28
+ ### [8.0.5] - 2026-08-03
29
+
30
+ **Fixed**
31
+
32
+ * `QueueRepository#find_all` no longer lists queues across every vhost on the broker. It called `list_queues` without a vhost, which lists every vhost's queues -- on a broker shared with other applications, that meant every other application's queues too, not just this one's.
33
+ * Sanitize the event name before building the routing key.
34
+ * [Breaking change?] Publish only Flu's own events from `flu_publish_events!`. It called `run_callbacks(:commit)`, which runs every `after_commit` callback registered on the record (including the host application's own (mailers, jobs, cache invalidation)) rather than just the one `track_entity_changes` installs. It now calls `flu_commit_changes` directly, the same way the real `after_commit` callback does.
35
+ * Guard the railtie require on `Rails::Railtie` rather than on `Rails`. Gems such as `rails-html-sanitizer` define an empty `Rails` namespace, which `is_testing_environment?` already accounted for a few lines below -- the railtie require itself did not, and raised `NameError: uninitialized constant Rails::Railtie` in that case.
36
+ * Stop assuming `ActionDispatch` is loaded when serializing an event. `Event#to_json` referenced `ActionDispatch::Http::UploadedFile` unconditionally to special-case uploaded files, on every value it serialized. A plain script that builds and serializes an event without the rest of Rails loaded raised `NameError: uninitialized constant Flu::Event::ActionDispatch` on the very first field.
37
+ * Track Single Table Inheritance subclasses. ActiveRecord hands the callbacks registered by `track_entity_changes` down to subclasses, but the class-level instance variables holding their settings were not inherited, so every save of an STI subclass of a tracked model raised `NoMethodError` on `nil`. `flu_is_tracked`, `flu_user_metadata_lambdas`, `flu_ignored_model_changes` and `flu_overriden_emitter_lambda` are now declared with `class_attribute`, which a subclass inherits until it sets its own value.
38
+ * Always clear the per-request tracking state, even when an action raises. The request id and the entity metadata are stored in thread-local storage and were cleared from an `after_action`, which does not run when the action raises. Since application servers reuse their threads, a failed request left its id behind for the next request served by that same thread, whose entity changes were then attributed to a request that had already died. `track_requests` now installs a single `around_action` that clears both from an `ensure` block.
39
+
40
+ **Added**
41
+
42
+ * `rabbitmq_vhost` configuration option (default `"/"`), used by both `EventPublisher`'s AMQP connection and `QueueRepository`'s management API calls, to operate on a vhost other than the default.
43
+ * `Flu::Error`, the base class of every error this gem raises on its own, and `Flu::NotConnectedError`.
44
+
45
+ **Changed**
46
+
47
+ * `EventFactory` no longer serializes an event to JSON before logging it, unless debug logging is actually enabled.
48
+ * Publishing through a publisher that holds no connection now raises `Flu::NotConnectedError`, naming what to call and the option that turns the automatic connection off, instead of a `NoMethodError` on `nil`.
49
+ * A `request` event is now built once every other `after_action` has run, so its `duration` covers them too. An action that raises still publishes nothing.
50
+ * `EventPublisher` opens one Bunny channel per thread instead of sharing a single one.
51
+ * `EventPublisher#connected?` now reports on the connection alone. The exchange is declared per thread, so it is no longer a single object whose absence says anything about the publisher as a whole.
52
+
53
+ **Performance**
54
+
55
+ * Optimize `deep_camelize` to use `each_with_object` and `ActiveSupport::Inflector#camelize` to improve performance and reduce memory consumption.
56
+ * Add `# frozen_string_literal: true` to all ruby files to reduce string allocations.
57
+
58
+ **Tests**
59
+
60
+ * Cover `rabbitmq_vhost` against a real broker: `EventPublisher#connect` fails rather than silently falling back to the default vhost when it points at one that does not exist, and `QueueRepository#find_all` scoped to a dedicated vhost does not see a queue declared on another one -- proving the cross-vhost leak above was real.
61
+ * Cover `duration`, which had no test at all: it is the elapsed wall-clock time since `request_start_time`, and a real controller request emits one that is a small, non-negative `Float`.
62
+ * Cover `Event#to_routing_key`: dots are stripped from the name, spaces are left untouched, a name that would push the routing key past 255 characters is truncated with a warning through `Flu.logger`, and none of this raises when `Flu.logger` was never set. Cover the same against a real broker: a name with dots is delivered under a single routing-key segment, and a 300-character name that used to make Bunny raise is published and delivered instead.
63
+ * Cover that `flu_publish_events!` does not run a host application's own `after_commit` callback, registered on the record's singleton class, exactly the way `run_callbacks(:commit)` used to run it too.
64
+ * Cover loading `flu-rails` with an empty `Rails` namespace defined, through the same kind of subprocess as the `ActionDispatch` example above.
65
+ * Cover that building an entity change, request or manual event does not serialize it when the logger's level is above `DEBUG`.
66
+ * Cover `Event#to_json` without `ActionDispatch` loaded, through a subprocess that never requires actionpack -- the main suite always has it loaded, so this is the only honest way to exercise the guard.
67
+ * Cover which channel a thread publishes on, against a real broker: each thread gets its own, reuses it across publications, and picks up a fresh one after the connection was closed under it.
68
+ * Cover `entity_metadata`, which had no test at all: its lambda is evaluated before the action runs, and its result is cleared afterwards.
69
+ * Cover what a raising action leaves behind in the thread-local tracking state.
70
+ * Add `simplecov` as a development dependency to track and report code coverage.
71
+ * Add support for the `RABBITMQ_VERSION` environment variable in `docker-compose.yml` to allow testing locally against different versions of RabbitMQ.
72
+ * Configure GitHub Actions CI to run the test suite against a matrix of RabbitMQ versions (3.12, 3.13, and 4.0).
73
+
8
74
  ### [8.0.4] - 2026-08-01
9
75
 
10
76
  **Security**
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"
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`)
@@ -43,6 +43,7 @@ Each configuration is detailed below.
43
43
  * RabbitMQ: no specific version is required by `flu-rails` itself. The broker is reached through
44
44
  `bunny` 3.x and `rabbitmq_http_api_client` 3.x, which target the
45
45
  [currently supported RabbitMQ release series](https://www.rabbitmq.com/release-information).
46
+ It is actively tested against RabbitMQ 3.12, 3.13, and 4.x.
46
47
 
47
48
  ## Usage
48
49
 
@@ -167,6 +168,8 @@ All options have a default value. However, all of them can be changed in your in
167
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` |
168
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` |
169
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) }` |
170
173
 
171
174
  ## How to execute tests
172
175
 
@@ -187,6 +190,14 @@ your machine, start a broker first:
187
190
  $ bundle exec rspec
188
191
  ```
189
192
 
193
+ You can optionally specify the RabbitMQ version to use by providing the `RABBITMQ_VERSION` environment variable (defaults to `4-management-alpine`):
194
+
195
+ ```
196
+ $ RABBITMQ_VERSION=3.13-management-alpine docker compose up -d rabbitmq
197
+ ```
198
+
199
+ Our continuous integration pipeline (via GitHub Actions) systematically runs the integration test suite against a matrix of RabbitMQ versions (3.12, 3.13, and 4.x) to ensure seamless backward and forward compatibility.
200
+
190
201
  Without one, those examples are reported as skipped and the rest of the suite still runs. Set
191
202
  `FLU_REQUIRE_RABBITMQ` to turn that skip into a failure — this is what the CI workflow does, so that
192
203
  a build cannot report success without having reached RabbitMQ:
@@ -304,8 +315,8 @@ scoped RubyGems credential.
304
315
  3. Tag the commit and push the tag:
305
316
 
306
317
  ```
307
- $ git tag -a v8.0.4 -m "Version 8.0.4"
308
- $ git push origin v8.0.4
318
+ $ git tag -a v8.0.6 -m "Version 8.0.6"
319
+ $ git push origin v8.0.6
309
320
  ```
310
321
 
311
322
  The workflow then checks that the tag matches `Flu::VERSION`, runs the tests, builds the gem
data/flu-rails.gemspec CHANGED
@@ -35,5 +35,6 @@ Gem::Specification.new do |spec|
35
35
  spec.add_development_dependency "ostruct", "~> 0.6"
36
36
  spec.add_development_dependency "rake", ">= 13.0"
37
37
  spec.add_development_dependency "rspec", "~> 3.13"
38
+ spec.add_development_dependency "simplecov"
38
39
  spec.add_development_dependency "sqlite3", "~> 2.9"
39
40
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support/core_ext/string/inflections"
2
4
  require "active_support/core_ext/time/zones"
3
5
  require "random/formatter"
@@ -69,19 +71,17 @@ module Flu
69
71
  end
70
72
  end
71
73
 
72
- before_action do
74
+ prepend_around_action do |_controller, action|
73
75
  flu_define_request_id
74
- @request_start_time = Time.zone.now
75
- end
76
- prepend_before_action do
77
76
  flu_define_request_entity_metadata
78
- end
79
- prepend_after_action do
80
- flu_track_request
81
- flu_remove_request_entity_metadata
82
- end
83
- after_action do
84
- flu_remove_request_id
77
+ @request_start_time = Time.zone.now
78
+ begin
79
+ action.call
80
+ flu_track_request
81
+ ensure
82
+ flu_remove_request_entity_metadata
83
+ flu_remove_request_id
84
+ end
85
85
  end
86
86
  end
87
87
  end
@@ -1,14 +1,32 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support/core_ext/object/try"
4
+ require "active_support/notifications"
2
5
 
3
6
  module Flu
4
7
  class ActiveRecordExtender
5
8
  def self.extend_models(event_factory, event_publisher)
9
+ publish_on_transaction_commit
10
+
6
11
  ActiveRecord::Base.class_eval do
12
+ unless singleton_class.method_defined?(:flu_is_tracked)
13
+ class_attribute :flu_is_tracked, instance_accessor: false, default: false
14
+ class_attribute :flu_user_metadata_lambdas, instance_accessor: false, default: {}.freeze
15
+ class_attribute :flu_ignored_model_changes, instance_accessor: false, default: [].freeze
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
21
+ end
22
+
7
23
  define_singleton_method(:track_entity_changes) do |options = {}|
8
24
  self.flu_is_tracked = true
9
25
  self.flu_user_metadata_lambdas = options.fetch(:user_metadata, {})
10
26
  self.flu_ignored_model_changes = options.fetch(:ignored_model_changes, []).map(&:to_s)
11
27
  self.flu_overriden_emitter_lambda = options.fetch(:emitter, nil)
28
+ self.flu_event_factory = event_factory
29
+ self.flu_event_publisher = event_publisher
12
30
 
13
31
  after_create { flu_track_entity_change(:create, saved_changes, event_factory) }
14
32
  after_update { flu_track_entity_change(:update, saved_changes, event_factory) }
@@ -17,38 +35,6 @@ module Flu
17
35
  after_rollback { flu_rollback_changes }
18
36
  end
19
37
 
20
- def self.flu_ignored_model_changes=(ignored_model_changes)
21
- @flu_ignored_model_changes = ignored_model_changes
22
- end
23
-
24
- def self.flu_ignored_model_changes
25
- @flu_ignored_model_changes
26
- end
27
-
28
- def self.flu_overriden_emitter_lambda=(overriden_emitter_lambda)
29
- @flu_overriden_emitter_lambda = overriden_emitter_lambda
30
- end
31
-
32
- def self.flu_overriden_emitter_lambda
33
- @flu_overriden_emitter_lambda
34
- end
35
-
36
- def self.flu_user_metadata_lambdas=(user_metadata_lambdas)
37
- @flu_user_metadata_lambdas = user_metadata_lambdas
38
- end
39
-
40
- def self.flu_user_metadata_lambdas
41
- @flu_user_metadata_lambdas
42
- end
43
-
44
- def self.flu_is_tracked=(is_tracked)
45
- @flu_is_tracked = is_tracked
46
- end
47
-
48
- def self.flu_is_tracked
49
- @flu_is_tracked || false
50
- end
51
-
52
38
  def self.flu_association_columns
53
39
  @flu_association_columns ||= reflect_on_all_associations(:belongs_to).flat_map do |association|
54
40
  column_names = [association.foreign_key]
@@ -61,8 +47,16 @@ module Flu
61
47
  @flu_changes ||= []
62
48
  end
63
49
 
64
- def flu_publish_events!
65
- run_callbacks(:commit)
50
+ # Defined with 'define_method' rather than 'def', for the same reason as the tracking
51
+ # helpers in 'ActionControllerExtender#extend_controllers': a bare 'def' opens a new scope
52
+ # and would not close over 'event_factory' and 'event_publisher'.
53
+ #
54
+ # Without them, publishing manually had no way to call 'flu_commit_changes' directly,
55
+ # and used 'run_callbacks(:commit)' instead, which runs *every* 'after_commit' callback on the record,
56
+ # including the hoste application's own (mailers, jobs, cache invalidation), not just Flu's.
57
+ define_method(:flu_publish_events!) do
58
+ flu_commit_changes(self.class.flu_event_factory || event_factory,
59
+ self.class.flu_event_publisher || event_publisher)
66
60
  end
67
61
 
68
62
  def flu_add_manual_event(name, data)
@@ -72,24 +66,37 @@ module Flu
72
66
  data: data,
73
67
  flu_is_a_manual_event: true
74
68
  })
69
+ Flu::TransactionBuffer.current.record(self) if self.class.flu_is_tracked
75
70
  end
76
71
 
77
72
  def flu_changes_as_events(event_factory)
78
- flu_changes.select do |data|
79
- !data[:changes].try(:empty?) || data[:flu_is_a_manual_event]
80
- end.map do |data|
81
- if data[:flu_is_a_manual_event]
82
- event_factory.build_manual_event(data[:name], data[:data])
83
- else
84
- event_factory.build_entity_change_event(data)
85
- 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)
86
87
  end
87
88
  end
88
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.
89
92
  def flu_commit_changes(event_factory, event_publisher)
90
- 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)
91
95
  event_publisher.publish(event)
96
+ rescue StandardError => error
97
+ Flu.publication_failed(event, error, event_publisher)
92
98
  end
99
+ ensure
93
100
  flu_flush_changes
94
101
  end
95
102
 
@@ -114,10 +121,49 @@ module Flu
114
121
  self.class.flu_association_columns,
115
122
  self.class.flu_ignored_model_changes,
116
123
  self.class.flu_overriden_emitter_lambda)
117
- flu_changes.push(data) unless data.nil?
124
+ return if data.nil?
125
+ flu_changes.push(data)
126
+ Flu::TransactionBuffer.current.record(self)
118
127
  end
119
128
  end
120
129
  end
121
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
122
168
  end
123
169
  end
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
4
  class Configuration
3
5
  attr_accessor :development_environments,
4
6
  :rejected_user_agents,
5
7
  :logger,
6
8
  :rabbitmq_host,
9
+ :rabbitmq_vhost,
7
10
  :rabbitmq_management_scheme,
8
11
  :rabbitmq_management_port,
9
12
  :rabbitmq_port,
@@ -15,6 +18,8 @@ module Flu
15
18
  :default_ignored_model_changes,
16
19
  :default_ignored_request_params,
17
20
  :application_name,
18
- :bunny_options
21
+ :bunny_options,
22
+ :on_publication_failure,
23
+ :max_pending_events
19
24
  end
20
25
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "active_record_extender"
2
4
  require_relative "action_controller_extender"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
4
  module Dummy
3
5
  class InMemoryEventPublisher < Flu::EventPublisher
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flu
4
+ class Error < StandardError
5
+ end
6
+
7
+ class NotConnectedError < Error
8
+ end
9
+
10
+ class ConnectionLostError < Error
11
+ end
12
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module Flu
@@ -22,8 +24,11 @@ module Flu
22
24
  @data = data || {}
23
25
  end
24
26
 
27
+ ROUTING_KEY_MAX_LENGTH = 255
28
+
25
29
  def to_routing_key
26
- "#{@meta[:status]}.#{@meta[:emitter]}.#{@meta[:kind]}.#{@meta[:name]}"
30
+ prefix = "#{@meta[:status]}.#{@meta[:emitter]}.#{@meta[:kind]}."
31
+ "#{prefix}#{routing_key_name(prefix.length)}"
27
32
  end
28
33
 
29
34
  def to_json(options=nil)
@@ -67,12 +72,22 @@ module Flu
67
72
 
68
73
  private
69
74
 
75
+ def routing_key_name(prefix_length)
76
+ name = @meta[:name].to_s.delete(".")
77
+ budget = [ROUTING_KEY_MAX_LENGTH - prefix_length, 0].max
78
+ return name if name.length <= budget
79
+
80
+ Flu.logger&.warn("flu-rails: event name '#{@meta[:name]}' was truncated to #{budget} " \
81
+ "characters to keep its routing key within Bunny's #{ROUTING_KEY_MAX_LENGTH}-character limit.")
82
+ name[0, budget]
83
+ end
84
+
70
85
  def map_complex_object(object)
71
86
  if object.is_a?(Array)
72
87
  map_array(object)
73
88
  elsif object.is_a?(Hash)
74
89
  map_hash(object)
75
- elsif object.is_a?(ActionDispatch::Http::UploadedFile)
90
+ elsif defined?(ActionDispatch::Http::UploadedFile) && object.is_a?(ActionDispatch::Http::UploadedFile)
76
91
  map_file(object)
77
92
  else
78
93
  object
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "securerandom"
2
4
  require "active_support/core_ext/object/blank"
3
5
  require "active_support/core_ext/string/inflections"
@@ -20,7 +22,7 @@ module Flu
20
22
  raise ArgumentError, "data must have an controller_name" if !data.has_key?(:controller_name) || data[:controller_name].empty?
21
23
  name = "request to #{data[:action_name]} #{data[:controller_name]}"
22
24
  event = build_event(name, :request, data)
23
- @logger.debug("Track action: #{JSON.pretty_generate(event)}")
25
+ @logger.debug { "Track action: #{event.to_json}" }
24
26
  event
25
27
  end
26
28
 
@@ -31,7 +33,7 @@ module Flu
31
33
  raise ArgumentError, "data must have an entity_name" if !data.has_key?(:entity_name) || data[:entity_name].empty?
32
34
  name = "#{data[:action_name]} #{data[:entity_name]}"
33
35
  event = build_event(name, :entity_change, data)
34
- @logger.debug("Track change: " + JSON.pretty_generate(event))
36
+ @logger.debug { "Track change: #{event.to_json}" }
35
37
  event
36
38
  end
37
39
 
@@ -40,7 +42,7 @@ module Flu
40
42
  raise ArgumentError, "data must be a hash" if !data.is_a?(Hash)
41
43
  raise ArgumentError, "name must not be nil or empty" if name.nil? || name.empty?
42
44
  event = build_event(name.to_s, :manual, data)
43
- @logger.debug("Track manual: " + JSON.pretty_generate(event))
45
+ @logger.debug { "Track manual: #{event.to_json}" }
44
46
  event
45
47
  end
46
48
 
@@ -91,25 +93,16 @@ module Flu
91
93
  def deep_camelize(value)
92
94
  case value
93
95
  when Array
94
- value.map { |v| deep_camelize v }
96
+ value.map { |v| deep_camelize(v) }
95
97
  when Hash
96
- value.reduce({}) do |camelized_hash, (k,v)|
97
- camelized_hash[camelize(sanitize(k.to_s), false)] = deep_camelize v
98
- camelized_hash
98
+ value.each_with_object({}) do |(k, v), camelized_hash|
99
+ camelized_hash[sanitize(k.to_s).camelize(:lower)] = deep_camelize(v)
99
100
  end
100
101
  else
101
102
  sanitize(value)
102
103
  end
103
104
  end
104
105
 
105
- def camelize(lower_case_and_underscored_word, first_letter_in_uppercase = true)
106
- if first_letter_in_uppercase
107
- lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
108
- else
109
- (lower_case_and_underscored_word[0] || "") + camelize(lower_case_and_underscored_word)[1..-1]
110
- end
111
- end
112
-
113
106
  def extract_associations_from(entity, association_columns)
114
107
  association_columns.reduce({}) do |associations, column_name|
115
108
  associations[column_name] = entity[column_name]
@@ -1,74 +1,118 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bunny"
4
+ require_relative "errors"
2
5
  require_relative "event"
3
6
 
4
7
 
5
8
  module Flu
6
9
  class EventPublisher
10
+ NOT_CONNECTED_MESSAGE = "no connection to RabbitMQ: 'connect' was never called, or " \
11
+ "'disconnect' was. The railtie calls it at boot unless " \
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
+
7
17
  def initialize(configuration)
8
18
  @logger = configuration.logger
9
19
  @configuration = configuration
20
+ @mutex = Mutex.new
10
21
  end
11
22
 
12
23
  def publish(event, persistent=true)
13
24
  routing_key = event.to_routing_key
14
- @logger.debug("Publishing event with id '#{event.id}' with routing key: #{routing_key}")
15
- @exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
16
- @logger.debug("Event published.")
25
+ @logger.debug { "Publishing event with id '#{event.id}' with routing key: #{routing_key}" }
26
+ exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
27
+ @logger.debug { "Event published." }
28
+ rescue Bunny::ConnectionClosedError
29
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE
17
30
  end
18
31
 
19
32
  def connect
20
- unless connected?
21
- connected = false
22
- while !connected
23
- begin
24
- connect_to_exchange
25
- connected = true
26
- rescue Bunny::TCPConnectionFailedForAllHosts
27
- @logger.warn("RabbitMQ connection failed, try again in 1 second.")
28
- 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
29
44
  end
30
45
  end
31
46
  end
32
47
  end
33
48
 
34
49
  def connected?
35
- if @connection.nil? || @exchange.nil?
36
- false
37
- else
38
- @connection.open?
39
- end
50
+ !forked? && !@connection.nil? && @connection.open?
40
51
  end
41
52
 
42
- # Closing the connection closes the channel opened on it, and stops the heartbeat and recovery
43
- # threads Bunny runs alongside it.
44
- # The guard is on the connection alone, not on 'connected?': a connection that was opened before
45
- # the exchange could be declared still has to be closed.
53
+ # Closing the connection closes every channel opened on it, and stops the heartbeat and
54
+ # recovery threads Bunny runs alongside it.
55
+ # The guard is on the connection alone: a connection that was opened before the exchange could
56
+ # be declared still has to be closed.
57
+ # An inherited connection is dropped rather than closed: its socket is the parent's.
46
58
  def disconnect
47
- if !@connection.nil? && @connection.open?
48
- @connection.close
59
+ @mutex.synchronize do
60
+ @connection.close if connected?
61
+ @connection = nil
62
+ @pid = nil
63
+ Thread.current[exchange_key] = nil
49
64
  end
50
- @connection = nil
51
- @channel = nil
52
- @exchange = nil
53
65
  end
54
66
 
55
67
  private
56
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
+
79
+ # One channel per thread rather than one for the whole publisher.
80
+ # Bunny serialises every publication on the channel's own mutex.
81
+ #
82
+ # No bookkeeping of the channels handed out is needed.
83
+ # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
84
+ # 'disconnect' and a reconnection are both covered without reaching into other threads.
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.
87
+ def exchange
88
+ connect if forked?
89
+ cached = Thread.current[exchange_key]
90
+ return cached if cached && cached.channel.open?
91
+ raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
92
+ raise ConnectionLostError, CONNECTION_LOST_MESSAGE unless @connection.open?
93
+ Thread.current[exchange_key] = declare_exchange
94
+ end
95
+
96
+ def declare_exchange
97
+ channel = @connection.create_channel
98
+ channel.topic(@configuration.rabbitmq_exchange_name,
99
+ durable: @configuration.rabbitmq_exchange_durable)
100
+ end
101
+
57
102
  def connect_to_exchange
58
103
  options = {
59
104
  host: @configuration.rabbitmq_host,
105
+ vhost: @configuration.rabbitmq_vhost,
60
106
  port: @configuration.rabbitmq_port&.to_i,
61
107
  user: @configuration.rabbitmq_user,
62
108
  password: @configuration.rabbitmq_password,
63
109
  automatically_recover: true
64
110
  }.merge(@configuration.bunny_options || {})
65
-
111
+
66
112
  @connection = Bunny.new(options)
67
113
  @connection.start
68
- @channel = @connection.create_channel
69
- @exchange = @channel.send(:topic,
70
- @configuration.rabbitmq_exchange_name,
71
- durable: @configuration.rabbitmq_exchange_durable)
114
+ @pid = Process.pid
115
+ Thread.current[exchange_key] = declare_exchange
72
116
  end
73
117
  end
74
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rabbitmq/http/client"
2
4
 
3
5
  module Flu
@@ -9,23 +11,23 @@ module Flu
9
11
  end
10
12
 
11
13
  def find_all
12
- @management_client.list_queues
14
+ @management_client.list_queues(@configuration.rabbitmq_vhost)
13
15
  end
14
16
 
15
17
  def find_queue(name)
16
- @management_client.queue_info("/", name)
18
+ @management_client.queue_info(@configuration.rabbitmq_vhost, name)
17
19
  end
18
20
 
19
21
  def find_bindings_for_queue(name)
20
- @management_client.list_queue_bindings("/", name)
22
+ @management_client.list_queue_bindings(@configuration.rabbitmq_vhost, name)
21
23
  end
22
24
 
23
25
  def purge_queue(name)
24
- @management_client.purge_queue("/", name)
26
+ @management_client.purge_queue(@configuration.rabbitmq_vhost, name)
25
27
  end
26
28
 
27
29
  def delete_queue(name)
28
- @management_client.delete_queue("/", name)
30
+ @management_client.delete_queue(@configuration.rabbitmq_vhost, name)
29
31
  end
30
32
 
31
33
  private
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
4
  class Railtie < Rails::Railtie
3
5
  railtie_name :flu
@@ -13,5 +15,11 @@ module Flu
13
15
  Flu.init
14
16
  Flu.start
15
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
16
24
  end
17
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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
4
  module Util
3
5
  class ExportService
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
- VERSION = "8.0.4"
4
+ VERSION = "8.0.6"
3
5
  end
data/lib/flu-rails.rb CHANGED
@@ -1,16 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "logger"
2
4
  require "json"
3
5
  require "active_support/core_ext/module/introspection"
4
6
  require_relative "flu-rails/version"
7
+ require_relative "flu-rails/errors"
5
8
  require_relative "flu-rails/event"
6
9
  require_relative "flu-rails/event_factory"
7
10
  require_relative "flu-rails/queue_repository"
8
11
  require_relative "flu-rails/configuration"
9
12
  require_relative "flu-rails/core_ext"
13
+ require_relative "flu-rails/transaction_buffer"
14
+ require_relative "flu-rails/pending_publications"
10
15
  require_relative "flu-rails/event_publisher"
11
16
  require_relative "flu-rails/util"
12
17
  require_relative "flu-rails/dummy/in_memory_event_publisher"
13
- require_relative "flu-rails/railtie" if defined?(Rails)
18
+ require_relative "flu-rails/railtie" if defined?(Rails::Railtie)
14
19
 
15
20
  module Flu
16
21
  def self.configure
@@ -33,6 +38,36 @@ module Flu
33
38
  @event_publisher
34
39
  end
35
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
+
36
71
  def self.init
37
72
  @configuration.application_name ||= default_application_name
38
73
  raise "configuration.application_name must not be nil" if @configuration.application_name.nil?
@@ -87,6 +122,7 @@ module Flu
87
122
  config.rejected_user_agents = []
88
123
  config.logger = ::Logger.new(STDOUT)
89
124
  config.rabbitmq_host = "localhost"
125
+ config.rabbitmq_vhost = "/"
90
126
  config.rabbitmq_port = 5672
91
127
  config.rabbitmq_management_scheme = "http"
92
128
  config.rabbitmq_management_port = 15672
@@ -99,6 +135,8 @@ module Flu
99
135
  config.default_ignored_request_params = [:password, :password_confirmation, :controller, :action]
100
136
  config.application_name = nil
101
137
  config.bunny_options = {}
138
+ config.on_publication_failure = nil
139
+ config.max_pending_events = 1000
102
140
  end
103
141
  end
104
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
4
+ version: 8.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Loïc Vigneron
@@ -152,6 +152,20 @@ dependencies:
152
152
  - - "~>"
153
153
  - !ruby/object:Gem::Version
154
154
  version: '3.13'
155
+ - !ruby/object:Gem::Dependency
156
+ name: simplecov
157
+ requirement: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ type: :development
163
+ prerelease: false
164
+ version_requirements: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
155
169
  - !ruby/object:Gem::Dependency
156
170
  name: sqlite3
157
171
  requirement: !ruby/object:Gem::Requirement
@@ -185,11 +199,14 @@ files:
185
199
  - lib/flu-rails/configuration.rb
186
200
  - lib/flu-rails/core_ext.rb
187
201
  - lib/flu-rails/dummy/in_memory_event_publisher.rb
202
+ - lib/flu-rails/errors.rb
188
203
  - lib/flu-rails/event.rb
189
204
  - lib/flu-rails/event_factory.rb
190
205
  - lib/flu-rails/event_publisher.rb
206
+ - lib/flu-rails/pending_publications.rb
191
207
  - lib/flu-rails/queue_repository.rb
192
208
  - lib/flu-rails/railtie.rb
209
+ - lib/flu-rails/transaction_buffer.rb
193
210
  - lib/flu-rails/util.rb
194
211
  - lib/flu-rails/version.rb
195
212
  homepage: https://github.com/crepesourcing/flu-rails