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 +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +14 -3
- data/flu-rails.gemspec +1 -0
- data/lib/flu-rails/action_controller_extender.rb +11 -11
- data/lib/flu-rails/active_record_extender.rb +90 -44
- data/lib/flu-rails/configuration.rb +6 -1
- data/lib/flu-rails/core_ext.rb +2 -0
- data/lib/flu-rails/dummy/in_memory_event_publisher.rb +2 -0
- data/lib/flu-rails/errors.rb +12 -0
- data/lib/flu-rails/event.rb +17 -2
- data/lib/flu-rails/event_factory.rb +8 -15
- data/lib/flu-rails/event_publisher.rb +75 -31
- data/lib/flu-rails/pending_publications.rb +67 -0
- data/lib/flu-rails/queue_repository.rb +7 -5
- data/lib/flu-rails/railtie.rb +8 -0
- data/lib/flu-rails/transaction_buffer.rb +61 -0
- data/lib/flu-rails/util.rb +2 -0
- data/lib/flu-rails/version.rb +3 -1
- data/lib/flu-rails.rb +39 -1
- metadata +18 -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,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.
|
|
308
|
-
$ git push origin v8.0.
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/flu-rails/core_ext.rb
CHANGED
data/lib/flu-rails/event.rb
CHANGED
|
@@ -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]}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
96
|
+
value.map { |v| deep_camelize(v) }
|
|
95
97
|
when Hash
|
|
96
|
-
value.
|
|
97
|
-
camelized_hash[
|
|
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
|
|
15
|
-
|
|
16
|
-
@logger.debug
|
|
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
|
-
|
|
21
|
-
connected
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
# threads Bunny runs alongside it.
|
|
44
|
-
# The guard is on the connection alone
|
|
45
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
69
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
30
|
+
@management_client.delete_queue(@configuration.rabbitmq_vhost, name)
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
private
|
data/lib/flu-rails/railtie.rb
CHANGED
|
@@ -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
|
data/lib/flu-rails/util.rb
CHANGED
data/lib/flu-rails/version.rb
CHANGED
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
|
+
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
|