Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

24 changed files with 183 additions and 451 deletions

View File

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

3
.gitignore vendored
View File

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

View File

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

14
Gemfile
View File

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

View File

@ -1,139 +1,69 @@
PATH
remote: .
specs:
outboxable (1.0.6)
bunny (>= 2.22)
connection_pool (>= 2.4)
outboxable (0.1.0)
bunny (>= 2.19.0)
connection_pool (~> 2.3.0)
GEM
remote: https://rubygems.org/
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)
ast (2.4.3)
base64 (0.3.0)
bigdecimal (4.0.1)
bunny (2.22.0)
ast (2.4.2)
bunny (2.20.3)
amq-protocol (~> 2.3, >= 2.3.1)
sorted_set (~> 1, >= 1.0.2)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
cronex (0.15.0)
tzinfo
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)
connection_pool (2.3.0)
diff-lcs (1.5.0)
json (2.6.3)
parallel (1.22.1)
parser (3.2.1.0)
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)
rake (13.3.1)
rake (13.0.6)
rbtree (0.4.6)
redis-client (0.26.3)
connection_pool
regexp_parser (2.11.3)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
regexp_parser (2.7.0)
rexml (3.2.5)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.1)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
rubocop (1.82.1)
rspec-support (~> 3.12.0)
rspec-support (3.12.0)
rubocop (1.45.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.24.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
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)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.26.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.11.0)
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)
rbtree
set (~> 1.0)
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)
unicode-display_width (2.4.2)
PLATFORMS
arm64-darwin-25
x86_64-linux
DEPENDENCIES
activesupport (~> 8.1.2)
outboxable!
rake (~> 13.3.1)
rspec (~> 3.13.2)
rubocop-rails (~> 2.34.3)
sidekiq (~> 8.1.0)
sidekiq-cron (~> 2.3.1)
rake (~> 13.0)
rspec (~> 3.0)
rubocop (~> 1.21)
BUNDLED WITH
2.4.17
2.4.2

View File

@ -1,28 +1,15 @@
# 🚨 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
The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It supports both ActiveRecord and Mongoid.
The Outboxable Gem is tailored for Rails applications to implement the transactional outbox pattern. It currently only supports ActiveRecord.
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
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 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 [](https://github.com/sidekiq-cron/sidekiq-cron) to check the unpublished outboxes every 5 seconds after the initialization of the application.
## Installation
@ -38,19 +25,13 @@ If bundler is not being used to manage dependencies, install the gem by executin
$ gem install outboxable
```
For use with ActiveRecord, run:
Then run:
```shell
$ rails g outboxable:install --orm activerecord
$ rails g outboxable:install
```
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):
The command above will add a migration file and the Outbox model. You will need then to run the migrations:
```shell
$ rails db:migrate
@ -61,7 +42,7 @@ $ rails db:migrate
The installation command above will also add a configuration file to your initializer:
```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.
module Outboxable
module RabbitMq
@ -75,7 +56,7 @@ module Outboxable
specversion: '1.0',
type: resource.routing_key,
datacontenttype: 'application/json',
data: resource.payload
data: @resource.payload
}.to_json
end
end
@ -83,7 +64,7 @@ module Outboxable
end
Outboxable.configure do |config|
# Specify the ORM you are using. Supported values are :activerecord and :mongoid
# Specify the ORM you are using. For now, only ActiveRecord is supported.
config.orm = :activerecord
# Specify the message broker you are using. For now, only RabbitMQ is supported.
@ -92,14 +73,14 @@ Outboxable.configure do |config|
# 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_user = ENV.fetch('RABBITMQ__USER')
config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD')
config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST')
config.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME')
config.rabbitmq_event_bus_exchange = ENV.fetch('EVENTBUS__EXCHANGE_NAME')
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).
@ -174,7 +155,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.
@ -215,24 +196,18 @@ 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.
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
$ 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
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
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
require "bundler/gem_tasks"
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)
require 'rubocop/rake_task'
require "rubocop/rake_task"
RuboCop::RakeTask.new

View File

@ -2,48 +2,35 @@ module Outboxable
class InstallGenerator < Rails::Generators::Base
include Rails::Generators::Migration
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
source_root File.expand_path('../../../templates', __FILE__)
# Copy initializer into user app
def copy_initializer
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'
copy_file('initializer.rb', 'config/initializers/outboxable.rb')
end
# Copy user information (model & Migrations) into user app
def create_user_model
target_path = 'app/models/outbox.rb'
if Rails.root.join(target_path).exist?
say_status('skipped', 'Model outbox already exists')
target_path = "app/models/outbox.rb"
unless File.exist?(File.join(Rails.root, target_path))
template("outbox.rb", target_path)
else
template('activerecrod_outbox.rb', target_path) if @orm == 'activerecord'
template('mongoid_outbox.rb', target_path) if @orm == 'mongoid'
say_status('skipped', "Model outbox already exists")
end
end
# Copy migrations
def copy_migrations
return if @orm == 'mongoid'
if self.class.migration_exists?('db/migrate', 'create_outboxable_outboxes')
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
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
# Use to assign migration time otherwise generator will error
def self.next_migration_number(_dir)
Time.now.utc.strftime('%Y%m%d%H%M%S')
def self.next_migration_number(dir)
Time.now.utc.strftime("%Y%m%d%H%M%S")
end
end
end
end

View File

@ -1,32 +1,30 @@
# frozen_string_literal: true
require_relative 'outboxable/version'
require_relative "outboxable/version"
require 'outboxable/worker'
require 'outboxable/publishing_manager'
require 'outboxable/polling_publisher_worker'
require 'outboxable/connection'
require 'outboxable/configuration'
require 'outboxable/rabbitmq/publisher'
require 'active_support/concern'
require 'outboxable/publishing_manager'
require 'outboxable/polling_publisher_worker'
module Outboxable
class Error < StandardError; end
extend ActiveSupport::Concern
included do
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) }
has_many :outboxes, as: :outboxable, dependent: :destroy
has_many :outboxes, as: :outboxable, autosave: false
def instantiate_outbox(routing_key:)
def instantiate_outbox(routing_key: )
outboxes.new(
routing_key:,
exchange: Outboxable.configuration.rabbitmq_exchange_name,
exchange: Outboxable.configuration.rabbitmq_event_bus_exchange,
payload: as_json
)
end

View File

@ -10,14 +10,14 @@ module Outboxable
class Configuration
ALLOWED_MESSAGE_BROKERS = %i[rabbitmq].freeze
ALLOWED_ORMS = %i[activerecord mongoid].freeze
ALLOWED_ORMS = %i[activerecord].freeze
attr_accessor :rabbitmq_host,
attr_accessor :rabbitmq_host,
:rabbitmq_port,
:rabbitmq_user,
:rabbitmq_password,
:rabbitmq_vhost,
:rabbitmq_exchange_name,
:rabbitmq_event_bus_exchange,
:message_broker,
:orm
@ -31,7 +31,7 @@ module Outboxable
Sidekiq::Options[:cron_poll_interval] = 5
# 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
def message_broker=(message_broker)

View File

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

View File

@ -1,27 +1,14 @@
module Outboxable
class PollingPublisherWorker
include Sidekiq::Job
sidekiq_options queue: 'critical'
def perform
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|
Outbox.pending.find_in_batches(batch_size: 100).each do |batch|
batch.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.id)
outbox.update(last_attempted_at: 1.minute.from_now, status: :processing, allow_publish: false)
Outboxable::Worker.perform_async(outbox.id)
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

View File

@ -4,38 +4,34 @@ module Outboxable
def initialize(resource:)
@resource = resource
end
def to_envelope(resource:)
# 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
def publish
confirmed = nil
Outboxable::Connection.instance.channel.with do |channel|
channel.confirm_select
# Declare a exchange
exchange = channel.topic(@resource.exchange, durable: true)
# Publish the CloudEvent resource to the exchange
exchange.publish(
to_envelope(resource: @resource),
routing_key: @resource.routing_key,
headers: @resource.try(:headers) || {},
content_type: @resource.try(:content_type) || 'application/json'
)
exchange.publish(to_envelope(resource: @resource), routing_key: @resource.routing_key, headers: @resource.try(:headers) || {})
# Wait for confirmation
confirmed = channel.wait_for_confirms
end
return unless confirmed
@resource.reload
@resource.increment_attempt
@resource.update(status: :published, retry_at: nil)
end
end
end
end
end

View File

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

View File

@ -1,12 +1,9 @@
require 'sidekiq'
module Outboxable
class Worker
include ::Sidekiq::Job
include Sidekiq::Job
def perform(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
Outboxable::PublishingManager.publish(resource: Outbox.find(outbox_id))
end
end
end
end

View File

@ -1,34 +0,0 @@
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,7 +7,6 @@ class CreateOutboxableOutboxes < ActiveRecord::Migration[7.0]
t.string :exchange, 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.datetime :last_attempted_at, null: true
@ -18,11 +17,9 @@ class CreateOutboxableOutboxes < ActiveRecord::Migration[7.0]
t.integer :size, null: false, default: 0
t.references :outboxable, polymorphic: true, null: true
t.references :outboxable, polymorphic: true, null: true
t.timestamps
end
add_index :outboxes, %i[status last_attempted_at], name: 'index_outboxes_on_outboxable_status_and_last_attempted_at'
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.
module Outboxable
module RabbitMq
@ -12,7 +12,7 @@ module Outboxable
specversion: '1.0',
type: resource.routing_key,
datacontenttype: 'application/json',
data: resource.payload
data: @resource.payload
}.to_json
end
end
@ -23,14 +23,14 @@ Outboxable.configure do |config|
# Specify the ORM you are using. For now, only ActiveRecord is supported.
config.orm = :activerecord
# Specify the ORM you are using. Supported values are :activerecord and :mongoid
# Specify the message broker you are using. For now, only RabbitMQ is supported.
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_user = ENV.fetch('RABBITMQ__USER')
config.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD')
config.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST')
config.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME')
end
config.rabbitmq_event_bus_exchange = ENV.fetch('EVENTBUS__EXCHANGE_NAME')
end

View File

@ -1,36 +0,0 @@
# 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

@ -1,72 +0,0 @@
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

32
lib/templates/outbox.rb Normal file
View File

@ -0,0 +1,32 @@
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
require_relative 'lib/outboxable/version'
require_relative "lib/outboxable/version"
Gem::Specification.new do |spec|
spec.name = 'outboxable'
spec.name = "outboxable"
spec.version = Outboxable::VERSION
spec.authors = ['Brusk Awat']
spec.email = ['broosk.edogawa@gmail.com']
spec.authors = ["Brusk Awat"]
spec.email = ["broosk.edogawa@gmail.com"]
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.homepage = 'https://github.com/broosk1993/outboxable'
spec.license = 'MIT'
spec.required_ruby_version = '>= 3.1.2'
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.homepage = "https://githuh.com/broosk1993/outboxable"
spec.license = "MIT"
spec.required_ruby_version = ">= 2.6.0"
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
spec.metadata['homepage_uri'] = spec.homepage
spec.metadata['source_code_uri'] = 'https://github.com/broosk1993/outboxable'
spec.metadata['changelog_uri'] = 'https://github.com/broosk1993/outboxable/blob/main/CHANGELOG.md'
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://githuh.com/broosk1993/outboxable"
spec.metadata["changelog_uri"] = "https://githuh.com/broosk1993/outboxable/CHANGELOG.md"
# 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.
@ -27,12 +27,10 @@ Gem::Specification.new do |spec|
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
end
end
spec.bindir = 'exe'
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']
spec.add_dependency 'bunny', '>= 2.22'
spec.add_dependency 'connection_pool', '>= 2.4'
spec.metadata['rubygems_mfa_required'] = 'true'
spec.require_paths = ["lib"]
spec.add_dependency 'bunny', '>= 2.19.0'
spec.add_dependency 'connection_pool', '~> 2.3.0'
end

View File

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

View File

@ -1,10 +1,10 @@
# frozen_string_literal: true
require 'outboxable'
require "outboxable"
RSpec.configure do |config|
# 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`
config.disable_monkey_patching!
@ -12,8 +12,4 @@ RSpec.configure do |config|
config.expect_with :rspec do |c|
c.syntax = :expect
end
config.before(:each) do
Sidekiq::Worker.clear_all
end
end