Compare commits

...

65 Commits
v0.1.1 ... main

Author SHA1 Message Date
Brusk Awat
8a64179e38
Merge pull request #8 from muhammadnawzad/main
Updates Dependancies
2026-01-18 10:39:04 +03:00
Muhammad Nawzad
f9bfeac427
refactor: remove metadata field from outboxable templates. 2026-01-18 10:06:03 +03:00
Muhammad Nawzad
46f56a4a08
refactor: remove metadata argument from outbox creation. 2026-01-18 10:04:30 +03:00
Muhammad Nawzad
4a6952f767
Upgrade Ruby to 4.0.1 in CI, update gem dependencies, and apply RuboCop configuration and style fixes. 2026-01-18 10:02:40 +03:00
Muhammad Nawzad
61767b40c2
Adds metadata to outboxes 2024-08-19 11:06:17 +03:00
Brusk Awat
08ef9242e5
Update README.md 2024-08-04 14:01:35 +03:00
40cbf3342a
Merge pull request #4 from muhammadnawzad/main
Indexes columns & adds `content_type` field
2024-05-28 14:22:41 +03:00
Muhammad Nawzad
816465eced
Refactors code for better readablity 2024-05-28 13:51:17 +03:00
Muhammad Nawzad
035a9822ee
Adds content_type configuration 2024-05-28 13:44:54 +03:00
Muhammad Nawzad
e5ebfb84d0
Add index on outboxes table for status and last_attempted_at 2024-04-02 14:50:42 +03:00
Muhammad Nawzad
de7c304524
Fixes initialize 2024-04-02 14:50:27 +03:00
faa35a5791
Merge pull request #3 from muhammadnawzad/main
Updates the variable rabbitmq_event_bus_exchange's name to rabbitmq_e…
2023-12-14 10:59:53 +03:00
Muhammad Nawzad
a8765e15f6
Updates .gitignore 2023-12-06 13:45:57 +03:00
Muhammad Nawzad
664adbb401
Bumps gem version to 1.0.6 2023-12-06 13:45:25 +03:00
Muhammad Nawzad
13b2013f3f
Removes extra argument 2023-12-06 13:45:08 +03:00
Muhammad Nawzad
4de6891945
Updates required gems' versions 2023-12-06 13:44:50 +03:00
Muhammad Nawzad
eb6de394bc
Merge branch 'ditkrg:main' into main 2023-12-06 13:34:18 +03:00
e486de9bb1
Bumps version 2023-04-25 12:39:34 +03:00
873f23ba59
Runs publish inline after commit 2023-04-25 12:39:20 +03:00
Muhammad Nawzad
5ef8d6a51d
Updates the variable rabbitmq_event_bus_exchange's name to rabbitmq_exchange_name 2023-04-18 10:42:55 +03:00
6f598f40d4
Fixes typo 2023-04-16 16:55:39 +03:00
3b545b0676
Updates documentation 2023-04-14 04:18:16 +03:00
92ee8d2eea
Major improvements 2023-04-14 04:10:57 +03:00
5a927b7c6a
Bumps version 2023-04-14 02:39:24 +03:00
e4c2638261
Improves mongoid model 2023-04-14 02:39:02 +03:00
bab6309502
Bug fixes and improvements 2023-04-14 02:38:32 +03:00
ee8dae21d5
Bumps version 2023-04-13 23:44:14 +03:00
a5153324d5
Separates polling publisher for mongoid from activerecord 2023-04-13 23:38:42 +03:00
64c23f9796
Uses respective orm templates 2023-04-13 22:11:13 +03:00
daf9b980ea
Separates mongoid templates from activerecord 2023-04-13 22:10:15 +03:00
aee1128c54
Adds mongo simple enum 2023-04-13 22:09:48 +03:00
ae5e53d5c0
Remove autosave: false 2023-04-13 20:58:47 +03:00
15e1c9011a
Adds circuit-breaking 2023-04-13 12:16:18 +03:00
3e10bd9768
Update version.rb 2023-04-13 12:12:32 +03:00
689d4effe8
Update outbox.rb 2023-04-13 12:12:14 +03:00
35496ea7b5
Bumps to 0.1.6 2023-04-11 14:09:19 +03:00
286d11984b
Adds transitionary state 2023-04-10 10:31:50 +03:00
aaf2f57d06
Bumps version 2023-04-06 17:18:32 +03:00
fe26826675
Removes increment 2023-04-06 17:17:35 +03:00
cf5f49691f
Adds initial 10 seconds as last_attempted_at so that it is not picked again by polling publisher 2023-04-06 17:17:28 +03:00
02bd4dcc00
Fixes gems 2023-04-06 00:02:36 +03:00
b45eb3a0a7
Removes .gem 2023-04-06 00:01:32 +03:00
18aaa8bc26
Fixes rubocop offencses 2023-04-05 23:56:59 +03:00
e6f41a0cf9
Merge branch 'main' of github.com:broosk1993/outboxable 2023-04-05 23:54:26 +03:00
160641416e
Increases version 2023-04-05 23:52:07 +03:00
2647c47b53
Adds mechanism to avoid too many retries 2023-04-05 23:52:00 +03:00
fd69f23345
Cleans up sidekiq to avoid tests being run between tests 2023-04-05 23:51:44 +03:00
7bf316e309
Adds basic specs 2023-04-05 23:51:27 +03:00
6793ac10c5
Sets last_attempted_at at creation time to avoid null 2023-04-05 23:51:15 +03:00
723481af3d
Explicitly requires sidekiq 2023-04-05 23:50:56 +03:00
53e3f655e6
Explicitly requires active_support 2023-04-05 23:50:30 +03:00
6f93829d3c
Adds development gems 2023-04-05 23:50:15 +03:00
7a163b3d39
Merge pull request #2 from muhammadnawzad/patch-1
Updates Home Repository URL
2023-03-12 17:48:42 +03:00
Muhammad Nawzad
27852eb589
Merge branch 'main' into patch-1 2023-03-12 16:31:30 +03:00
Muhammad Nawzad
9b6e6e7102
Changes RABBITMQ__USER to RABBITMQ__USERNAME 2023-03-12 16:29:20 +03:00
c00d458ba5
Update version.rb 2023-03-12 15:45:14 +03:00
0213750ea3
Fixes disgusting bug 2023-03-12 15:44:50 +03:00
Muhammad Nawzad
51b03c5452
Removes activesupport and sidekiq-cron from the gemfile to allow users to add them manually to their gemfiles 2023-03-08 12:32:12 +03:00
Muhammad Nawzad
b229e07dc8
Changes required ruby version 3.1.2 2023-03-06 18:25:11 +03:00
Muhammad Nawzad
ba9028f2a9
Changes required ruby version from 2.6.0 to 3.0.0 2023-03-06 18:20:33 +03:00
Muhammad Nawzad
8c408fe7f6
Fixes Rubocop Offenses 2023-03-06 16:20:35 +03:00
Muhammad Nawzad
918d4f9700
Updates rubocop gem with rubocop-rails gem 2023-03-06 16:05:02 +03:00
Muhammad Nawzad
e08006e456
Requires sidekiq-cron and activesupport 2023-03-06 15:58:07 +03:00
Muhammad Nawzad
6d56b1f57a
Updates CHANGELOG URL 2023-03-06 14:59:07 +03:00
Muhammad Nawzad
0bf9f7c976
Updates Home Repository URL 2023-03-06 14:52:15 +03:00
24 changed files with 445 additions and 177 deletions

View File

@ -14,14 +14,14 @@ jobs:
strategy: strategy:
matrix: matrix:
ruby: ruby:
- '3.1.2' - "4.0.1"
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: ${{ matrix.ruby }} ruby-version: ${{ matrix.ruby }}
bundler-cache: true bundler-cache: true
- name: Run the default task - name: Run the default task
run: bundle exec rake run: bundle exec rake

3
.gitignore vendored
View File

@ -9,3 +9,6 @@
# rspec failure tracking # rspec failure tracking
.rspec_status .rspec_status
*.gem
.idea

View File

@ -1,11 +1,12 @@
require: rubocop-rails plugins:
- rubocop-rails
AllCops: AllCops:
NewCops: enable NewCops: enable
SuggestExtensions: false SuggestExtensions: false
Layout/SpaceBeforeBrackets: # (new in 1.7) Layout/SpaceBeforeBrackets: # (new in 1.7)
Enabled: true Enabled: true
Layout/LineLength: Layout/LineLength:
Max: 350 Max: 350
Lint/AmbiguousAssignment: # (new in 1.7) Lint/AmbiguousAssignment: # (new in 1.7)
Enabled: true Enabled: true
@ -110,4 +111,8 @@ Metrics/MethodLength:
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 15 Max: 15
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 15 Max: 15
Lint/DuplicateMethods: # Disables duplicate methods warning
Enabled: false
Gemspec/RequiredRubyVersion: # Disables required ruby version warning
Enabled: false

14
Gemfile
View File

@ -1,12 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
source "https://rubygems.org" source 'https://rubygems.org'
# Specify your gem's dependencies in outboxable.gemspec # Specify your gem's dependencies in outboxable.gemspec
gemspec gemspec
gem "rake", "~> 13.0" gem 'rake', '~> 13.3.1'
gem 'rspec', '~> 3.13.2'
gem 'rubocop-rails', '~> 2.34.3'
gem "rspec", "~> 3.0" group :development, :test do
gem 'activesupport', '~> 8.1.2'
gem "rubocop", "~> 1.21" gem 'sidekiq', '~> 8.1.0'
gem 'sidekiq-cron', '~> 2.3.1'
end

View File

@ -1,69 +1,139 @@
PATH PATH
remote: . remote: .
specs: specs:
outboxable (0.1.1) outboxable (1.0.6)
bunny (>= 2.19.0) bunny (>= 2.22)
connection_pool (~> 2.3.0) connection_pool (>= 2.4)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
activesupport (8.1.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
amq-protocol (2.3.2) amq-protocol (2.3.2)
ast (2.4.2) ast (2.4.3)
bunny (2.20.3) base64 (0.3.0)
bigdecimal (4.0.1)
bunny (2.22.0)
amq-protocol (~> 2.3, >= 2.3.1) amq-protocol (~> 2.3, >= 2.3.1)
sorted_set (~> 1, >= 1.0.2) sorted_set (~> 1, >= 1.0.2)
connection_pool (2.3.0) concurrent-ruby (1.3.6)
diff-lcs (1.5.0) connection_pool (3.0.2)
json (2.6.3) cronex (0.15.0)
parallel (1.22.1) tzinfo
parser (3.2.1.0) unicode (>= 0.4.4.5)
diff-lcs (1.6.2)
drb (2.2.3)
et-orbi (1.4.0)
tzinfo
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
json (2.18.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (6.0.1)
prism (~> 1.5)
parallel (1.27.0)
parser (3.3.10.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
prism (1.8.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.3.1)
rbtree (0.4.6) rbtree (0.4.6)
regexp_parser (2.7.0) redis-client (0.26.3)
rexml (3.2.5) connection_pool
rspec (3.12.0) regexp_parser (2.11.3)
rspec-core (~> 3.12.0) rspec (3.13.2)
rspec-expectations (~> 3.12.0) rspec-core (~> 3.13.0)
rspec-mocks (~> 3.12.0) rspec-expectations (~> 3.13.0)
rspec-core (3.12.1) rspec-mocks (~> 3.13.0)
rspec-support (~> 3.12.0) rspec-core (3.13.6)
rspec-expectations (3.12.2) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-mocks (3.12.3) rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-support (3.12.0) rspec-support (3.13.6)
rubocop (1.45.1) rubocop (1.82.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.0.0) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.48.0, < 2.0)
rubocop-ast (>= 1.24.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.26.0) rubocop-ast (1.49.0)
parser (>= 3.2.1.0) parser (>= 3.3.7.2)
ruby-progressbar (1.11.0) prism (~> 1.7)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
set (1.0.3) set (1.0.3)
sidekiq (8.1.0)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
sidekiq-cron (2.3.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sorted_set (1.0.3) sorted_set (1.0.3)
rbtree rbtree
set (~> 1.0) set (~> 1.0)
unicode-display_width (2.4.2) tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
PLATFORMS PLATFORMS
arm64-darwin-25
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
activesupport (~> 8.1.2)
outboxable! outboxable!
rake (~> 13.0) rake (~> 13.3.1)
rspec (~> 3.0) rspec (~> 3.13.2)
rubocop (~> 1.21) rubocop-rails (~> 2.34.3)
sidekiq (~> 8.1.0)
sidekiq-cron (~> 2.3.1)
BUNDLED WITH BUNDLED WITH
2.4.2 2.4.17

View File

@ -1,12 +1,25 @@
# 🚨 Discontinuation Notice for ActiveRecord 🚨
**Effective Date: August 4, 2024**
Please be aware that we are no longer maintaing the part related to **ActiveRecord** in this gem. We are dropping support for ActiveRecord in favor of [Solid Queue](https://github.com/rails/solid_queue).
In the meantime, we commit to continously support the Mongoid part of the gem.
### New Recommended Gem: `Solid Queue`
For ActiveRecord users, we recommend transitioning to the `Solid Queue` gem, which provides enhanced functionality, improved performance, and better support for modern application requirements. `Solid Queue` is designed to seamlessly integrate with your existing infrastructure while offering robust features to handle your queuing needs efficiently.
# Outboxable # Outboxable
The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It currently only supports ActiveRecord. The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It supports both ActiveRecord and Mongoid.
Please take into consideration that this Gem is **opinionated**, meaning it expects you to follow a certain pattern and specific setting. If you don't like it, you can always fork it and change it. Please take into consideration that this Gem is **opinionated**, meaning it expects you to follow a certain pattern and specific setting. If you don't like it, you can always fork it and change it.
### Restrictions ### Restrictions
1. When using RabbitMQ, it only publishes events to a ***topic*** exchange. 1. When using RabbitMQ, it only publishes events to a ***topic*** exchange.
1. It assumes that you are using routing keys to publish to the topic exchange. 1. It assumes that you are using routing keys to publish to the topic exchange.
1. It publishes events in a background job using [Sidekiq](https://github.com/sidekiq/sidekiq). Therefore, you application must use Sidekiq. 1. It publishes events in a background job using [Sidekiq](https://github.com/sidekiq/sidekiq). Therefore, you application must use Sidekiq.
1. It implements the [polling publisher pattern](https://microservices.io/patterns/data/polling-publisher.html). For that, it uses [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron) to check the unpublished outboxes every 5 seconds after the initialization of the application. 1. It implements the [polling publisher pattern](https://microservices.io/patterns/data/polling-publisher.html). For that, it uses [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron) to check the unpublished outboxes every 5 seconds after the initialization of the application.
@ -25,13 +38,19 @@ If bundler is not being used to manage dependencies, install the gem by executin
$ gem install outboxable $ gem install outboxable
``` ```
Then run: For use with ActiveRecord, run:
```shell ```shell
$ rails g outboxable:install $ rails g outboxable:install --orm activerecord
``` ```
The command above will add a migration file and the Outbox model. You will need then to run the migrations: For use with Mongoid, run:
```shell
$ rails g outboxable:install --orm mongoid
```
The command above will add a migration file and the Outbox model. You will need then to run the migrations (ActiveRecord only):
```shell ```shell
$ rails db:migrate $ rails db:migrate
@ -42,7 +61,7 @@ $ rails db:migrate
The installation command above will also add a configuration file to your initializer: The installation command above will also add a configuration file to your initializer:
```ruby ```ruby
# This monkey patch allows you to customize the message format that you publish to your broker. # This monkey patch allows you to customize the message format that you publish to your broker.
# By default, Outboxable publishes a CloudEvent message to your broker. # By default, Outboxable publishes a CloudEvent message to your broker.
module Outboxable module Outboxable
module RabbitMq module RabbitMq
@ -64,7 +83,7 @@ module Outboxable
end end
Outboxable.configure do |config| Outboxable.configure do |config|
# Specify the ORM you are using. For now, only ActiveRecord is supported. # Specify the ORM you are using. Supported values are :activerecord and :mongoid
config.orm = :activerecord config.orm = :activerecord
# Specify the message broker you are using. For now, only RabbitMQ is supported. # Specify the message broker you are using. For now, only RabbitMQ is supported.
@ -73,14 +92,14 @@ Outboxable.configure do |config|
# RabbitMQ configurations # RabbitMQ configurations
config.rabbitmq_host = ENV.fetch('RABBITMQ__HOST') config.rabbitmq_host = ENV.fetch('RABBITMQ__HOST')
config.rabbitmq_port = ENV.fetch('RABBITMQ__PORT', 5672) config.rabbitmq_port = ENV.fetch('RABBITMQ__PORT', 5672)
config.rabbitmq_user = ENV.fetch('RABBITMQ__USER') config.rabbitmq_user = ENV.fetch('RABBITMQ__USERNAME')
config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD') config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD')
config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST') config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST')
config.rabbitmq_event_bus_exchange = ENV.fetch('EVENTBUS__EXCHANGE_NAME') config.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME')
end end
``` ```
The monkey patch in the code above is crucial in giving you a way to customize the format of the message that you will publish to the message broker. Be default, it follows the specs of the [Cloud Native Events Specifications v1.0.2](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md). The monkey patch in the code above is crucial in giving you a way to customize the format of the message that you will publish to the message broker. Be default, it follows the specs of the [Cloud Native Events Specifications v1.0.2](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md).
@ -155,7 +174,7 @@ end
The ``outbox_configurations`` method will be called and used by the Outboxable Gem to transactionally create an outbox and publish. In the code above, it will create an outbox when the book is created. For that purpose it will use the routing key ``books.created`` as a convention. It will also publish an event if the book is updated, using the routing key: ``books.published`` since it was specified in the hash. The ``outbox_configurations`` method will be called and used by the Outboxable Gem to transactionally create an outbox and publish. In the code above, it will create an outbox when the book is created. For that purpose it will use the routing key ``books.created`` as a convention. It will also publish an event if the book is updated, using the routing key: ``books.published`` since it was specified in the hash.
@ -196,18 +215,24 @@ Here's the schema of what could be passed to the ``outbox_configurations`` in JS
The ``run_on`` key represents another hash that can have the keys ``create`` and ``update``. If one of these keys are not supplied, the outbox will not be created for the unspecified operation; in other words, if you do not specify the configuration for ``update``, for example, an outbox will NOT be created when the book is updated. The ``run_on`` key represents another hash that can have the keys ``create`` and ``update``. If one of these keys are not supplied, the outbox will not be created for the unspecified operation; in other words, if you do not specify the configuration for ``update``, for example, an outbox will NOT be created when the book is updated.
Each operation key such as ``create`` and ``update`` can also take a ``condition`` key, which represents a Ruby proc that must return a boolean expression. It can also take a ``routing_key`` option, which specifies that routing key with which the outbox will publish the event to the message broker. If you don't specify the ``routing_key``, it will use the base`s routing key dotted by``created`` for create operation and ``updated`` for update operation. Each operation key such as ``create`` and ``update`` can also take a ``condition`` key, which represents a Ruby proc that must return a boolean expression. It can also take a ``routing_key`` option, which specifies that routing key with which the outbox will publish the event to the message broker. If you don't specify the ``routing_key``, it will use the base`s routing key dotted by``created`` for create operation and ``updated`` for update operation.
Last but not least, run sidekiq so that the Outboxable Gem can publish the events to the broker: Last but not least, run sidekiq so that the Outboxable Gem can publish the events to the broker:
```shell ```shell
$ bundle exec sidekiq $ bundle exec sidekiq
``` ```
### Mongoid
The Outboxable gem works smoothly with Mongoid. It is to be noted that when used with Mongoid, Outboxable does not use the `_id` as the idempotency key. It creates a field called ``idempotency_key`` which is a UUID generated at the time of the insertion of the document.
## Development ## Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require "bundler/gem_tasks" require 'bundler/gem_tasks'
require "rspec/core/rake_task" require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec) RSpec::Core::RakeTask.new(:spec)
require "rubocop/rake_task" require 'rubocop/rake_task'
RuboCop::RakeTask.new RuboCop::RakeTask.new

View File

@ -2,35 +2,48 @@ module Outboxable
class InstallGenerator < Rails::Generators::Base class InstallGenerator < Rails::Generators::Base
include Rails::Generators::Migration include Rails::Generators::Migration
source_root File.expand_path('../../../templates', __FILE__) source_root File.expand_path('../../templates', __dir__)
class_option :orm, type: :string, default: 'activerecord'
def initialize(*args)
super(*args) # rubocop:disable Style/SuperArguments
@orm = options[:orm] || 'activerecord'
%w[activerecord mongoid].include?(@orm) || raise(ArgumentError, 'Invalid ORM. Only ActiveRecord and Mongoid are supported.')
end
# Copy initializer into user app # Copy initializer into user app
def copy_initializer def copy_initializer
copy_file('initializer.rb', 'config/initializers/z_outboxable.rb') copy_file('activerecord_initializer.rb', 'config/initializers/z_outboxable.rb') if @orm == 'activerecord'
copy_file('mongoid_initializer.rb', 'config/initializers/z_outboxable.rb') if @orm == 'mongoid'
end end
# Copy user information (model & Migrations) into user app # Copy user information (model & Migrations) into user app
def create_user_model def create_user_model
target_path = "app/models/outbox.rb" target_path = 'app/models/outbox.rb'
unless File.exist?(File.join(Rails.root, target_path))
template("outbox.rb", target_path) if Rails.root.join(target_path).exist?
say_status('skipped', 'Model outbox already exists')
else else
say_status('skipped', "Model outbox already exists") template('activerecrod_outbox.rb', target_path) if @orm == 'activerecord'
template('mongoid_outbox.rb', target_path) if @orm == 'mongoid'
end end
end end
# Copy migrations # Copy migrations
def copy_migrations def copy_migrations
if self.class.migration_exists?('db/migrate', "create_outboxable_outboxes") return if @orm == 'mongoid'
say_status('skipped', "Migration create_outboxable_outboxes already exists")
if self.class.migration_exists?('db/migrate', 'create_outboxable_outboxes')
say_status('skipped', 'Migration create_outboxable_outboxes already exists')
else else
migration_template('create_outboxable_outboxes.rb', "db/migrate/create_outboxable_outboxes.rb") migration_template('create_outboxable_outboxes.rb', 'db/migrate/create_outboxable_outboxes.rb')
end end
end end
# Use to assign migration time otherwise generator will error # Use to assign migration time otherwise generator will error
def self.next_migration_number(dir) def self.next_migration_number(_dir)
Time.now.utc.strftime("%Y%m%d%H%M%S") Time.now.utc.strftime('%Y%m%d%H%M%S')
end end
end end
end end

View File

@ -1,30 +1,32 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "outboxable/version" require_relative 'outboxable/version'
require 'outboxable/worker' require 'outboxable/worker'
require 'outboxable/publishing_manager'
require 'outboxable/polling_publisher_worker'
require 'outboxable/connection' require 'outboxable/connection'
require 'outboxable/configuration' require 'outboxable/configuration'
require 'outboxable/rabbitmq/publisher' require 'outboxable/rabbitmq/publisher'
require 'active_support/concern'
require 'outboxable/publishing_manager'
require 'outboxable/polling_publisher_worker'
module Outboxable module Outboxable
class Error < StandardError; end class Error < StandardError; end
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
after_create :instantiate_outbox_for_create, if: proc { |object| object.check_outbox_condition(object:, operation: :create) } after_create :instantiate_outbox_for_create, if: proc { |object| object.check_outbox_condition(object:, operation: :create) }
after_update :instantiate_outbox_for_update, if: proc { |object| object.check_outbox_condition(object:, operation: :update) } after_update :instantiate_outbox_for_update, if: proc { |object| object.check_outbox_condition(object:, operation: :update) }
has_many :outboxes, as: :outboxable, autosave: false has_many :outboxes, as: :outboxable, dependent: :destroy
def instantiate_outbox(routing_key: ) def instantiate_outbox(routing_key:)
outboxes.new( outboxes.new(
routing_key:, routing_key:,
exchange: Outboxable.configuration.rabbitmq_event_bus_exchange, exchange: Outboxable.configuration.rabbitmq_exchange_name,
payload: as_json payload: as_json
) )
end end

View File

@ -10,14 +10,14 @@ module Outboxable
class Configuration class Configuration
ALLOWED_MESSAGE_BROKERS = %i[rabbitmq].freeze ALLOWED_MESSAGE_BROKERS = %i[rabbitmq].freeze
ALLOWED_ORMS = %i[activerecord].freeze ALLOWED_ORMS = %i[activerecord mongoid].freeze
attr_accessor :rabbitmq_host, attr_accessor :rabbitmq_host,
:rabbitmq_port, :rabbitmq_port,
:rabbitmq_user, :rabbitmq_user,
:rabbitmq_password, :rabbitmq_password,
:rabbitmq_vhost, :rabbitmq_vhost,
:rabbitmq_event_bus_exchange, :rabbitmq_exchange_name,
:message_broker, :message_broker,
:orm :orm
@ -31,7 +31,7 @@ module Outboxable
Sidekiq::Options[:cron_poll_interval] = 5 Sidekiq::Options[:cron_poll_interval] = 5
# Create the cron job for the polling publisher # Create the cron job for the polling publisher
Sidekiq::Cron::Job.create(name: 'OutboxablePollingPublisher', cron: '*/5 * * * * *', class: 'Outboxable::PollingPublisherWorker') Sidekiq::Cron::Job.create(name: 'OutboxablePollingPublisher', cron: '*/5 * * * * *', class: 'Outboxable::PollingPublisherWorker')
end end
def message_broker=(message_broker) def message_broker=(message_broker)

View File

@ -3,15 +3,16 @@ require 'singleton'
module Outboxable module Outboxable
class Connection class Connection
include ::Singleton include ::Singleton
attr_reader :connection attr_reader :connection
def initialize def initialize
@connection = Bunny.new( @connection = Bunny.new(
host: RabbitCarrots.configuration.rabbitmq_host, host: Outboxable.configuration.rabbitmq_host,
port: RabbitCarrots.configuration.rabbitmq_port, port: Outboxable.configuration.rabbitmq_port,
user: RabbitCarrots.configuration.rabbitmq_user, user: Outboxable.configuration.rabbitmq_user,
password: RabbitCarrots.configuration.rabbitmq_password, password: Outboxable.configuration.rabbitmq_password,
vhost: RabbitCarrots.configuration.rabbitmq_vhost vhost: Outboxable.configuration.rabbitmq_vhost
) )
@connection.start @connection.start

View File

@ -1,14 +1,27 @@
module Outboxable module Outboxable
class PollingPublisherWorker class PollingPublisherWorker
include Sidekiq::Job include Sidekiq::Job
sidekiq_options queue: 'critical'
def perform def perform
Outbox.pending.find_in_batches(batch_size: 100).each do |batch| Outboxable.configuration.orm == :mongoid ? perform_mongoid : perform_activerecord
end
def perform_activerecord
Outbox.pending.where(last_attempted_at: [..Time.zone.now, nil]).find_in_batches(batch_size: 100).each do |batch|
batch.each do |outbox| batch.each do |outbox|
Outboxable::Worker.perform_async(outbox.id) # This is to prevent a job from being retried too many times. Worst-case scenario is 1 minute delay in jobs.
::Outboxable::Worker.perform_async(outbox.id)
outbox.update(last_attempted_at: 1.minute.from_now, status: :processing, allow_publish: false)
end end
end end
end end
def perform_mongoid
Outbox.pending.any_of({ last_attempted_at: ..Time.zone.now }, { last_attempted_at: nil }).each do |outbox|
# This is to prevent a job from being retried too many times. Worst-case scenario is 1 minute delay in jobs.
::Outboxable::Worker.perform_async(outbox.idempotency_key)
outbox.update(last_attempted_at: 1.minute.from_now, status: :processing, allow_publish: false)
end
end
end end
end end

View File

@ -4,34 +4,38 @@ module Outboxable
def initialize(resource:) def initialize(resource:)
@resource = resource @resource = resource
end end
def to_envelope(resource:) def to_envelope(resource:)
# throw not implemented method error # throw not implemented method error
raise NotImplementedError, "Please implement the to_envelope method in your own module" raise NotImplementedError, 'Please implement the to_envelope method in your own module'
end end
def publish def publish
confirmed = nil confirmed = nil
Outboxable::Connection.instance.channel.with do |channel| Outboxable::Connection.instance.channel.with do |channel|
channel.confirm_select channel.confirm_select
# Declare a exchange # Declare a exchange
exchange = channel.topic(@resource.exchange, durable: true) exchange = channel.topic(@resource.exchange, durable: true)
# Publish the CloudEvent resource to the exchange # Publish the CloudEvent resource to the exchange
exchange.publish(to_envelope(resource: @resource), routing_key: @resource.routing_key, headers: @resource.try(:headers) || {}) exchange.publish(
to_envelope(resource: @resource),
routing_key: @resource.routing_key,
headers: @resource.try(:headers) || {},
content_type: @resource.try(:content_type) || 'application/json'
)
# Wait for confirmation # Wait for confirmation
confirmed = channel.wait_for_confirms confirmed = channel.wait_for_confirms
end end
return unless confirmed return unless confirmed
@resource.reload @resource.reload
@resource.increment_attempt
@resource.update(status: :published, retry_at: nil) @resource.update(status: :published, retry_at: nil)
end end
end end
end end
end end

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Outboxable module Outboxable
VERSION = "0.1.1" VERSION = '1.0.6'
end end

View File

@ -1,9 +1,12 @@
require 'sidekiq'
module Outboxable module Outboxable
class Worker class Worker
include Sidekiq::Job include ::Sidekiq::Job
def perform(outbox_id) def perform(outbox_id)
Outboxable::PublishingManager.publish(resource: Outbox.find(outbox_id)) Outboxable::PublishingManager.publish(resource: Outbox.find(outbox_id)) if Outboxable.configuration.orm == :activerecord
Outboxable::PublishingManager.publish(resource: Outbox.find_by!(idempotency_key: outbox_id)) if Outboxable.configuration.orm == :mongoid
end end
end end
end end

View File

@ -1,4 +1,4 @@
# This monkey patch allows you to customize the message format that you publish to your broker. # This monkey patch allows you to customize the message format that you publish to your broker.
# By default, Outboxable publishes a CloudEvent message to your broker. # By default, Outboxable publishes a CloudEvent message to your broker.
module Outboxable module Outboxable
module RabbitMq module RabbitMq
@ -23,14 +23,14 @@ Outboxable.configure do |config|
# Specify the ORM you are using. For now, only ActiveRecord is supported. # Specify the ORM you are using. For now, only ActiveRecord is supported.
config.orm = :activerecord config.orm = :activerecord
# Specify the message broker you are using. For now, only RabbitMQ is supported. # Specify the ORM you are using. Supported values are :activerecord and :mongoid
config.message_broker = :rabbitmq config.message_broker = :rabbitmq
# RabbitMQ configurations # RabbitMQ configurations
config.rabbitmq_host = ENV.fetch('RABBITMQ__HOST') config.rabbitmq_host = ENV.fetch('RABBITMQ__HOST')
config.rabbitmq_port = ENV.fetch('RABBITMQ__PORT', 5672) config.rabbitmq_port = ENV.fetch('RABBITMQ__PORT', 5672)
config.rabbitmq_user = ENV.fetch('RABBITMQ__USER') config.rabbitmq_user = ENV.fetch('RABBITMQ__USERNAME')
config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD') config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD')
config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST') config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST')
config.rabbitmq_event_bus_exchange = ENV.fetch('EVENTBUS__EXCHANGE_NAME') config.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME')
end end

View File

@ -0,0 +1,34 @@
class Outbox < ApplicationRecord
attribute :allow_publish, :boolean, default: true
before_save :check_publishing
# Callbacks
before_create :set_last_attempted_at
after_commit :publish, if: :allow_publish?
# Enums
enum status: { pending: 0, processing: 1, published: 2, failed: 3 }
enum size: { single: 0, batch: 1 }
# Validations
validates :payload, :exchange, :routing_key, presence: true
# Associations
belongs_to :outboxable, polymorphic: true, optional: true
def set_last_attempted_at
self.last_attempted_at = 10.seconds.from_now
end
def publish
# Run this in own thread
threaded = Thread.new do
Outboxable::Worker.perform_inline(id)
update(status: :processing, last_attempted_at: 1.minute.from_now, allow_publish: false)
end
threaded.join
end
def check_publishing
self.allow_publish = false if published?
end
end

View File

@ -7,6 +7,7 @@ class CreateOutboxableOutboxes < ActiveRecord::Migration[7.0]
t.string :exchange, null: false, default: '' t.string :exchange, null: false, default: ''
t.string :routing_key, null: false, default: '' t.string :routing_key, null: false, default: ''
t.string :content_type, null: false, default: 'application/json'
t.integer :attempts, null: false, default: 0 t.integer :attempts, null: false, default: 0
t.datetime :last_attempted_at, null: true t.datetime :last_attempted_at, null: true
@ -17,9 +18,11 @@ class CreateOutboxableOutboxes < ActiveRecord::Migration[7.0]
t.integer :size, null: false, default: 0 t.integer :size, null: false, default: 0
t.references :outboxable, polymorphic: true, null: true t.references :outboxable, polymorphic: true, null: true
t.timestamps t.timestamps
end end
add_index :outboxes, %i[status last_attempted_at], name: 'index_outboxes_on_outboxable_status_and_last_attempted_at'
end end
end end

View File

@ -0,0 +1,36 @@
# This monkey patch allows you to customize the message format that you publish to your broker.
# By default, Outboxable publishes a CloudEvent message to your broker.
module Outboxable
module RabbitMq
class Publisher
# Override this method to customize the message format that you publish to your broker
# DO NOT CHANGE THE METHOD SIGNATURE
def to_envelope(resource:)
{
id: resource.id,
source: 'http://localhost:3000',
specversion: '1.0',
type: resource.routing_key,
datacontenttype: 'application/json',
data: resource.payload
}.to_json
end
end
end
end
Outboxable.configure do |config|
# Specify the ORM you are using. For now, only ActiveRecord is supported.
config.orm = :mongoid
# Specify the ORM you are using. Supported values are :activerecord and :mongoid
config.message_broker = :rabbitmq
# RabbitMQ configurations
config.rabbitmq_host = ENV.fetch('RABBITMQ__HOST')
config.rabbitmq_port = ENV.fetch('RABBITMQ__PORT', 5672)
config.rabbitmq_user = ENV.fetch('RABBITMQ__USERNAME')
config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD')
config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST')
config.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME')
end

View File

@ -0,0 +1,72 @@
class Outbox
include Mongoid::Document
include Mongoid::Timestamps
attr_writer :allow_publish
# Fields
field :status, type: String, default: 'pending'
field :size, type: String, default: 'single'
field :exchange, type: String, default: ''
field :routing_key, type: String, default: ''
field :content_type, type: String, default: 'application/json'
field :attempts, type: Integer, default: 0
field :last_attempted_at, type: DateTime, default: nil
field :retry_at, type: DateTime, default: nil
field :idempotency_key, type: String
field :payload, type: Hash, default: {}
field :headers, type: Hash, default: {}
index({ idempotency_key: 1 }, { unique: true, name: 'idempotency_key_unique_index' })
before_save :check_publishing
before_create :set_idempotency_key
# Callbacks
before_create :set_last_attempted_at
after_save :publish, if: :allow_publish
# Validations
validates :payload, :exchange, :routing_key, presence: true
# Associations
belongs_to :outboxable, polymorphic: true, optional: true
def set_last_attempted_at
self.last_attempted_at = 10.seconds.from_now
end
def publish
Outboxable::Worker.perform_async(idempotency_key)
update(status: :processing, last_attempted_at: 1.minute.from_now, allow_publish: false)
end
def set_idempotency_key
self.idempotency_key = SecureRandom.uuid if idempotency_key.blank?
end
def check_publishing
self.allow_publish = false if published?
end
def allow_publish
return true if @allow_publish.nil?
@allow_publish
end
%w[pending processing published failed].each do |status|
define_method "#{status}?" do
self.status == status
end
# define scope
scope status, -> { where(status:) }
end
end

View File

@ -1,32 +0,0 @@
class Outbox < ApplicationRecord
attribute :allow_publish, :boolean, default: true
# Callbacks
after_commit :publish, if: :allow_publish?
before_save :check_publishing
# Enums
enum status: { pending: 0, published: 1, failed: 2 }
enum size: { single: 0, batch: 1 }
# Validations
validates :payload, presence: true
validates :exchange, presence: true
validates :routing_key, presence: true
# Associations
belongs_to :outboxable, polymorphic: true, optional: true
def increment_attempt
self.attempts = attempts + 1
self.last_attempted_at = Time.zone.now
end
def publish
Outboxable::Worker.perform_async(id)
end
def check_publishing
self.allow_publish = false if published?
end
end

View File

@ -1,24 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "lib/outboxable/version" require_relative 'lib/outboxable/version'
Gem::Specification.new do |spec| Gem::Specification.new do |spec|
spec.name = "outboxable" spec.name = 'outboxable'
spec.version = Outboxable::VERSION spec.version = Outboxable::VERSION
spec.authors = ["Brusk Awat"] spec.authors = ['Brusk Awat']
spec.email = ["broosk.edogawa@gmail.com"] spec.email = ['broosk.edogawa@gmail.com']
spec.summary = "An opiniated Gem for Rails applications to implement the transactional outbox pattern." spec.summary = 'An opiniated Gem for Rails applications to implement the transactional outbox pattern.'
spec.description = "The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It currently only supports ActiveRecord." spec.description = 'The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It currently only supports ActiveRecord.'
spec.homepage = "https://githuh.com/broosk1993/outboxable" spec.homepage = 'https://github.com/broosk1993/outboxable'
spec.license = "MIT" spec.license = 'MIT'
spec.required_ruby_version = ">= 2.6.0" spec.required_ruby_version = '>= 3.1.2'
spec.metadata['allowed_push_host'] = 'https://rubygems.org' spec.metadata['allowed_push_host'] = 'https://rubygems.org'
spec.metadata["homepage_uri"] = spec.homepage spec.metadata['homepage_uri'] = spec.homepage
spec.metadata["source_code_uri"] = "https://githuh.com/broosk1993/outboxable" spec.metadata['source_code_uri'] = 'https://github.com/broosk1993/outboxable'
spec.metadata["changelog_uri"] = "https://githuh.com/broosk1993/outboxable/CHANGELOG.md" spec.metadata['changelog_uri'] = 'https://github.com/broosk1993/outboxable/blob/main/CHANGELOG.md'
# Specify which files should be added to the gem when it is released. # Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git. # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@ -27,10 +27,12 @@ Gem::Specification.new do |spec|
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)}) (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
end end
end end
spec.bindir = "exe" spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"] spec.require_paths = ['lib']
spec.add_dependency 'bunny', '>= 2.19.0' spec.add_dependency 'bunny', '>= 2.22'
spec.add_dependency 'connection_pool', '~> 2.3.0' spec.add_dependency 'connection_pool', '>= 2.4'
spec.metadata['rubygems_mfa_required'] = 'true'
end end

View File

@ -1,11 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'sidekiq/testing'
RSpec.describe Outboxable do RSpec.describe Outboxable do
it "has a version number" do it 'has a version number' do
expect(Outboxable::VERSION).not_to be nil expect(Outboxable::VERSION).not_to be nil
end end
it "does something useful" do context 'polling publisher sidekiq worker' do
expect(false).to eq(true) it 'should be able to perform' do
expect do
Outboxable::PollingPublisherWorker.perform_async
end.to change(Outboxable::PollingPublisherWorker.jobs, :size).by(1)
end
end end
end end

View File

@ -1,10 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
require "outboxable" require 'outboxable'
RSpec.configure do |config| RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure # Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = ".rspec_status" config.example_status_persistence_file_path = '.rspec_status'
# Disable RSpec exposing methods globally on `Module` and `main` # Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching! config.disable_monkey_patching!
@ -12,4 +12,8 @@ RSpec.configure do |config|
config.expect_with :rspec do |c| config.expect_with :rspec do |c|
c.syntax = :expect c.syntax = :expect
end end
config.before(:each) do
Sidekiq::Worker.clear_all
end
end end