flu-rails 8.0.3 → 8.0.5

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: 53b6a5973d48efbc23dc72483a46a4eba091d295a4f8bd3e1c927cd3d9c06671
4
- data.tar.gz: c425c485c1f1dfe7e1c61382296c17b49108cf3a8b9a257df15e02ce6e96ff45
3
+ metadata.gz: 24d179705111c94185f7421297a692e1f962feeef2a9c2132a04e744d70a6503
4
+ data.tar.gz: 93526441dcfaea7cc3a5be4795c5e86cdb9a4acdbaf95b825e530259b3c89d64
5
5
  SHA512:
6
- metadata.gz: ec67a57f43091e09464f0d78444b224a2e76dd85c004c79d2d07a033ef5ca50cbc9882329e05a5b6d55c5d919d424fc6baa0435d2a96e5230142e50a3ba37029
7
- data.tar.gz: eb021576551ca0f52d874a9f9c66d8cb71d5194e9e36867a38bcb220b8df6c9cf97ddef6b299419292d7592c5591851fb1221fba2b6fafa605e4a070ece475a6
6
+ metadata.gz: bd84b67b67c9f209fbab6c5309d719719e3d9f19df12e1d7816acc6690c084dad052e8590cc39b85cfcc9477b6c187d1ec352a3a8e55a932c5f51d22c062e458
7
+ data.tar.gz: e7e0eab197a600c06a10c1bb6ec801499cfe3d55327e94232b565830b5ee6fa69c4a5bf7c19ab3082f0492917d83e6c36581ce53600d5cbf9ef0259df1b620b1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,81 @@ 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.5] - 2026-08-03
9
+
10
+ **Fixed**
11
+
12
+ * `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.
13
+ * Sanitize the event name before building the routing key.
14
+ * [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.
15
+ * 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.
16
+ * 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.
17
+ * 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.
18
+ * 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.
19
+
20
+ **Added**
21
+
22
+ * `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.
23
+ * `Flu::Error`, the base class of every error this gem raises on its own, and `Flu::NotConnectedError`.
24
+
25
+ **Changed**
26
+
27
+ * `EventFactory` no longer serializes an event to JSON before logging it, unless debug logging is actually enabled.
28
+ * 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`.
29
+ * 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.
30
+ * `EventPublisher` opens one Bunny channel per thread instead of sharing a single one.
31
+ * `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.
32
+
33
+ **Performance**
34
+
35
+ * Optimize `deep_camelize` to use `each_with_object` and `ActiveSupport::Inflector#camelize` to improve performance and reduce memory consumption.
36
+ * Add `# frozen_string_literal: true` to all ruby files to reduce string allocations.
37
+
38
+ **Tests**
39
+
40
+ * 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.
41
+ * 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`.
42
+ * 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.
43
+ * 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.
44
+ * Cover loading `flu-rails` with an empty `Rails` namespace defined, through the same kind of subprocess as the `ActionDispatch` example above.
45
+ * Cover that building an entity change, request or manual event does not serialize it when the logger's level is above `DEBUG`.
46
+ * 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.
47
+ * 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.
48
+ * Cover `entity_metadata`, which had no test at all: its lambda is evaluated before the action runs, and its result is cleared afterwards.
49
+ * Cover what a raising action leaves behind in the thread-local tracking state.
50
+ * Add `simplecov` as a development dependency to track and report code coverage.
51
+ * Add support for the `RABBITMQ_VERSION` environment variable in `docker-compose.yml` to allow testing locally against different versions of RabbitMQ.
52
+ * Configure GitHub Actions CI to run the test suite against a matrix of RabbitMQ versions (3.12, 3.13, and 4.0).
53
+
54
+ ### [8.0.4] - 2026-08-01
55
+
56
+ **Security**
57
+
58
+ * Honour the host application's `config.filter_parameters` when publishing a `request` event.
59
+
60
+ **Fixed**
61
+
62
+ * Stop opening two RabbitMQ connections on every boot. The railtie called `Flu.start
63
+ * Close the publisher that `Flu.init` replaces.
64
+
65
+ **Added**
66
+
67
+ * `EventPublisher#disconnect` and `EventPublisher#connected?`.
68
+
69
+ **Changed**
70
+
71
+ * `ignored_request_params` and `default_ignored_request_params` are now matched as strings.
72
+ * A published `request` event may now carry `"[FILTERED]"` in place of a param value.
73
+ * `EventPublisher#connect` is now idempotent: calling it on a connected publisher does nothing
74
+ instead of opening a second connection.
75
+
76
+ **Tests**
77
+
78
+ * Cover the connection lifecycle against a real broker: redundant `connect` calls, `disconnect`,
79
+ `connected?`, and reconnection after a disconnect — including an example asserting that no session
80
+ is left open behind the publisher.
81
+ * Cover `Flu.init` disconnecting the publisher it replaces.
82
+
8
83
  ### [8.0.3] - 2026-08-01
9
84
 
10
85
  * Use `uuid_v7` as Event ID (requires Ruby 3.3)
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.5"
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
 
@@ -164,7 +165,7 @@ All options have a default value. However, all of them can be changed in your in
164
165
  | `rabbitmq_exchange_durable` | `true` | Boolean | Optional | Make the RabbitMQ's exchange durable or not. From RabbitMQ's [documentation](https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchanges): _"Durable exchanges survive broker restart whereas transient exchanges do not (they have to be redeclared when broker comes back online)."_ | `false` |
165
166
  | `auto_connect_to_exchange` | `true`| Boolean | Optional | Thanks to `Railties`, `flu-rails` starts automatically when the Rails app boots. However, this can be useful to not connect RabbitMQ at start up. To do so, set `auto_connect_to_exchange` to `false`. | `false` |
166
167
  | `default_ignored_model_changes` | `[:password, :password_confirmation, :created_at, :updated_at]` | Boolean | Optional | By default, all these attributes will be ignored from model changes when creating an event. For instance, this means that timestamp fields (`created_at` and `updated_at`) are not monitored when they change. | `[]` |
167
- | `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. | `false` |
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 }` |
170
171
 
@@ -187,6 +188,14 @@ your machine, start a broker first:
187
188
  $ bundle exec rspec
188
189
  ```
189
190
 
191
+ You can optionally specify the RabbitMQ version to use by providing the `RABBITMQ_VERSION` environment variable (defaults to `4-management-alpine`):
192
+
193
+ ```
194
+ $ RABBITMQ_VERSION=3.13-management-alpine docker compose up -d rabbitmq
195
+ ```
196
+
197
+ 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.
198
+
190
199
  Without one, those examples are reported as skipped and the rest of the suite still runs. Set
191
200
  `FLU_REQUIRE_RABBITMQ` to turn that skip into a failure — this is what the CI workflow does, so that
192
201
  a build cannot report success without having reached RabbitMQ:
@@ -304,8 +313,8 @@ scoped RubyGems credential.
304
313
  3. Tag the commit and push the tag:
305
314
 
306
315
  ```
307
- $ git tag -a v8.0.3 -m "Version 8.0.3"
308
- $ git push origin v8.0.3
316
+ $ git tag -a v8.0.5 -m "Version 8.0.5"
317
+ $ git push origin v8.0.5
309
318
  ```
310
319
 
311
320
  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,9 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support/core_ext/object/try"
2
4
 
3
5
  module Flu
4
6
  class ActiveRecordExtender
5
7
  def self.extend_models(event_factory, event_publisher)
6
8
  ActiveRecord::Base.class_eval do
9
+ unless singleton_class.method_defined?(:flu_is_tracked)
10
+ class_attribute :flu_is_tracked, instance_accessor: false, default: false
11
+ class_attribute :flu_user_metadata_lambdas, instance_accessor: false, default: {}.freeze
12
+ class_attribute :flu_ignored_model_changes, instance_accessor: false, default: [].freeze
13
+ class_attribute :flu_overriden_emitter_lambda, instance_accessor: false, default: nil
14
+ end
15
+
7
16
  define_singleton_method(:track_entity_changes) do |options = {}|
8
17
  self.flu_is_tracked = true
9
18
  self.flu_user_metadata_lambdas = options.fetch(:user_metadata, {})
@@ -17,38 +26,6 @@ module Flu
17
26
  after_rollback { flu_rollback_changes }
18
27
  end
19
28
 
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
29
  def self.flu_association_columns
53
30
  @flu_association_columns ||= reflect_on_all_associations(:belongs_to).flat_map do |association|
54
31
  column_names = [association.foreign_key]
@@ -61,8 +38,15 @@ module Flu
61
38
  @flu_changes ||= []
62
39
  end
63
40
 
64
- def flu_publish_events!
65
- run_callbacks(:commit)
41
+ # Defined with 'define_method' rather than 'def', for the same reason as the tracking
42
+ # helpers in 'ActionControllerExtender#extend_controllers': a bare 'def' opens a new scope
43
+ # and would not close over 'event_factory' and 'event_publisher'.
44
+ #
45
+ # Without them, publishing manually had no way to call 'flu_commit_changes' directly,
46
+ # and used 'run_callbacks(:commit)' instead, which runs *every* 'after_commit' callback on the record,
47
+ # including the hoste application's own (mailers, jobs, cache invalidation), not just Flu's.
48
+ define_method(:flu_publish_events!) do
49
+ flu_commit_changes(event_factory, event_publisher)
66
50
  end
67
51
 
68
52
  def flu_add_manual_event(name, data)
@@ -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,
@@ -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
@@ -20,7 +22,14 @@ module Flu
20
22
 
21
23
  def connect
22
24
  end
23
-
25
+
26
+ def connected?
27
+ true
28
+ end
29
+
30
+ def disconnect
31
+ end
32
+
24
33
  def events_count
25
34
  @published_events_by_routing_key.map do | key, value |
26
35
  value.size
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flu
4
+ class Error < StandardError
5
+ end
6
+
7
+ class NotConnectedError < Error
8
+ end
9
+ 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"
@@ -11,7 +13,7 @@ module Flu
11
13
  @configuration = configuration
12
14
  @emitter = configuration.application_name
13
15
  @default_ignored_model_changes = configuration.default_ignored_model_changes.map(&:to_s)
14
- @default_ignored_request_params = configuration.default_ignored_request_params
16
+ @default_ignored_request_params = configuration.default_ignored_request_params.map(&:to_s)
15
17
  end
16
18
 
17
19
  def build_request_event(data)
@@ -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
 
@@ -77,39 +79,30 @@ module Flu
77
79
  response_code: response.status,
78
80
  user_agent: request.user_agent,
79
81
  duration: Time.zone.now - request_start_time,
80
- params: extract_params(params, ignored_request_params)
82
+ params: extract_params(request, ignored_request_params)
81
83
  }
82
84
  end
83
85
 
84
86
  private
85
87
 
86
- def extract_params(params, ignored_request_params)
87
- remaining = params.except(*ignored_request_params).except(*@default_ignored_request_params)
88
- remaining.respond_to?(:to_unsafe_h) ? remaining.to_unsafe_h.to_h : remaining.to_h
88
+ def extract_params(request, ignored_request_params)
89
+ excluded_params = ignored_request_params.map(&:to_s) + @default_ignored_request_params
90
+ request.filtered_parameters.except(*excluded_params)
89
91
  end
90
92
 
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,51 +1,96 @@
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
+
7
14
  def initialize(configuration)
8
15
  @logger = configuration.logger
9
16
  @configuration = configuration
17
+ @exchange_key = :"flu_exchange_#{object_id}" # Channels are cached per thread
10
18
  end
11
19
 
12
20
  def publish(event, persistent=true)
13
21
  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.")
22
+ @logger.debug { "Publishing event with id '#{event.id}' with routing key: #{routing_key}" }
23
+ exchange.publish(event.to_json, routing_key: routing_key, persistent: persistent)
24
+ @logger.debug { "Event published." }
17
25
  end
18
26
 
19
27
  def connect
20
- connected = false
21
- while !connected
22
- begin
23
- connect_to_exchange
24
- connected = true
25
- rescue Bunny::TCPConnectionFailedForAllHosts
26
- @logger.warn("RabbitMQ connection failed, try again in 1 second.")
27
- sleep 1
28
+ unless connected?
29
+ connected = false
30
+ while !connected
31
+ begin
32
+ connect_to_exchange
33
+ connected = true
34
+ rescue Bunny::TCPConnectionFailedForAllHosts
35
+ @logger.warn("RabbitMQ connection failed, try again in 1 second.")
36
+ sleep 1
37
+ end
28
38
  end
29
39
  end
30
40
  end
31
41
 
42
+ def connected?
43
+ !@connection.nil? && @connection.open?
44
+ end
45
+
46
+ # Closing the connection closes every channel opened on it, and stops the heartbeat and
47
+ # recovery threads Bunny runs alongside it.
48
+ # The guard is on the connection alone: a connection that was opened before the exchange could
49
+ # be declared still has to be closed.
50
+ def disconnect
51
+ if !@connection.nil? && @connection.open?
52
+ @connection.close
53
+ end
54
+ @connection = nil
55
+ Thread.current[@exchange_key] = nil
56
+ end
57
+
32
58
  private
33
59
 
60
+ # One channel per thread rather than one for the whole publisher.
61
+ # Bunny serialises every publication on the channel's own mutex.
62
+ #
63
+ # No bookkeeping of the channels handed out is needed.
64
+ # Closing the connection closes all of them, so a thread holding a closed channel simply opens a new one on its next publication:
65
+ # '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.
68
+ def exchange
69
+ cached = Thread.current[@exchange_key]
70
+ return cached if cached && cached.channel.open?
71
+ raise NotConnectedError, NOT_CONNECTED_MESSAGE if @connection.nil?
72
+ Thread.current[@exchange_key] = declare_exchange
73
+ end
74
+
75
+ def declare_exchange
76
+ channel = @connection.create_channel
77
+ channel.topic(@configuration.rabbitmq_exchange_name,
78
+ durable: @configuration.rabbitmq_exchange_durable)
79
+ end
80
+
34
81
  def connect_to_exchange
35
82
  options = {
36
83
  host: @configuration.rabbitmq_host,
84
+ vhost: @configuration.rabbitmq_vhost,
37
85
  port: @configuration.rabbitmq_port&.to_i,
38
86
  user: @configuration.rabbitmq_user,
39
87
  password: @configuration.rabbitmq_password,
40
88
  automatically_recover: true
41
89
  }.merge(@configuration.bunny_options || {})
42
-
90
+
43
91
  @connection = Bunny.new(options)
44
92
  @connection.start
45
- @channel = @connection.create_channel
46
- @exchange = @channel.send(:topic,
47
- @configuration.rabbitmq_exchange_name,
48
- durable: @configuration.rabbitmq_exchange_durable)
93
+ Thread.current[@exchange_key] = declare_exchange
49
94
  end
50
95
  end
51
96
  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,14 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Flu
2
4
  class Railtie < Rails::Railtie
3
5
  railtie_name :flu
4
6
 
7
+ # 'to_prepare' runs before eager loading, so that 'track_entity_changes' and 'track_requests'
8
+ # exist by the time the application's models and controllers are loaded. It runs again on every
9
+ # code reload, where 'Flu.init' builds a new publisher which has to be connected too.
10
+ #
11
+ # There is deliberately no 'after_initialize' hook here: it also runs at boot, right after this
12
+ # one, and the 'Flu.start' it used to make opened a second connection that replaced — and
13
+ # therefore leaked — the connection opened above.
5
14
  config.to_prepare do
6
15
  Flu.init
7
16
  Flu.start
8
17
  end
9
-
10
- config.after_initialize do
11
- Flu.start
12
- end
13
18
  end
14
19
  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.3"
4
+ VERSION = "8.0.5"
3
5
  end
data/lib/flu-rails.rb CHANGED
@@ -1,7 +1,10 @@
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"
@@ -10,7 +13,7 @@ require_relative "flu-rails/core_ext"
10
13
  require_relative "flu-rails/event_publisher"
11
14
  require_relative "flu-rails/util"
12
15
  require_relative "flu-rails/dummy/in_memory_event_publisher"
13
- require_relative "flu-rails/railtie" if defined?(Rails)
16
+ require_relative "flu-rails/railtie" if defined?(Rails::Railtie)
14
17
 
15
18
  module Flu
16
19
  def self.configure
@@ -38,6 +41,10 @@ module Flu
38
41
  raise "configuration.application_name must not be nil" if @configuration.application_name.nil?
39
42
  @logger = @configuration.logger
40
43
  @event_factory = Flu::EventFactory.new(@configuration)
44
+ # The railtie re-runs 'init' on every code reload. The publisher being replaced owns an open
45
+ # connection, its channel and Bunny's heartbeat thread: dropping the reference to it without
46
+ # closing it leaks all three for the lifetime of the process.
47
+ @event_publisher&.disconnect
41
48
  @event_publisher = create_event_publisher(@configuration)
42
49
  extend_models_and_controllers
43
50
  end
@@ -83,6 +90,7 @@ module Flu
83
90
  config.rejected_user_agents = []
84
91
  config.logger = ::Logger.new(STDOUT)
85
92
  config.rabbitmq_host = "localhost"
93
+ config.rabbitmq_vhost = "/"
86
94
  config.rabbitmq_port = 5672
87
95
  config.rabbitmq_management_scheme = "http"
88
96
  config.rabbitmq_management_port = 15672
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.3
4
+ version: 8.0.5
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,6 +199,7 @@ 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