Rdux is a lightweight, minimalistic Rails plugin designed to introduce event sourcing and audit logging capabilities to your Rails application. With Rdux, you can efficiently track and store the history of actions performed within your app, offering transparency and traceability for key processes.
Key Features
- Audit Logging ๐ Rdux stores sanitized input data, the name of module or class (action performer) responsible for processing them, processing results, and additional metadata in the database.
- Model Representation ๐ Before action is executed it gets stored in the database through the
Rdux::Action
model.Rdux::Action
is converted to theRdux::FailedAction
when it fails. These models can be nested, allowing for complex action structures. - Revert and Retry ๐
Rdux::Action
can be reverted.Rdux::FailedAction
retains the input data and processing results necessary for implementing custom mechanisms to retry failed actions. - Exception Handling and Recovery ๐ Rdux automatically creates a
Rdux::FailedAction
when an exception occurs during action execution. It retains theup_payload
and allows you to capture additional data usingopts[:up_result]
, ensuring all necessary information is available for retrying the action. - Metadata ๐ Metadata can include the ID of the authenticated resource responsible for performing a given action, as well as resource IDs from external systems related to the action. This creates a clear audit trail of who executed each action and on whose behalf.
- Streams ๐ Rdux enables the identification of action chains (streams) by utilizing resource IDs stored in metadata. This makes it easy to query and track related actions.
Rdux is designed to integrate seamlessly with your existing Rails application, offering a straightforward and powerful solution for managing and auditing key actions.
Add this line to your application's Gemfile:
gem 'rdux'
And then execute:
$ bundle
Or install it yourself as:
$ gem install rdux
Then install and run migrations:
$ bin/rails rdux:install:migrations
$ bin/rails db:migrate
JSONB
datatype instead of text
for Postgres.
To dispatch an action using Rdux, use the dispatch
method (aliased as perform
).
Definition:
def dispatch(action, payload, opts = {}, meta: nil)
alias perform dispatch
Arguments:
action
: The name of the module or class (action performer) that processes the action. This is stored in the database as an instance ofRdux::Action
, with itsname
attribute set toaction
(e.g.,Task::Create
).payload
(Hash): The input data passed as the first argument to thecall
orup
method of the action performer. The data is sanitized and stored in the database before being processed by the action performer. During deserialization, the keys in thepayload
are converted to strings.opts
(Hash): Optional parameters passed as the second argument to thecall
orup
method, if defined. This can help avoid redundant database queries (e.g., if you already have an ActiveRecord object available before callingRdux.perform
). A helper is available to facilitate this use case:(opts[:ars] || {}).each { |k, v| payload["#{k}_id"] = v.id }
, where:ars
represents ActiveRecord objects. Note thatopts
is not stored in the database, and thepayload
should be fully sufficient to perform an action.opts
provides an optimization.meta
(Hash): Additional metadata stored in the database alongside theaction
andpayload
. Thestream
key is particularly useful for specifying the stream of actions used during reversions. For example, astream
can be constructed based on the owner of the action.
Example:
Rdux.perform(
Task::Create,
{ task: { name: 'Foo bar baz' } },
{ ars: { user: current_user } },
meta: {
stream: { user_id: current_user.id, context: 'foo' },
bar: 'baz'
}
)
Action in Rdux is processed by an action performer which is a Plain Old Ruby Object (PORO) that implements a class or instance method call
or up
.
This method must return a Rdux::Result
struct
.
Optionally, an action can implement a class or instance method down
to specify how to revert it.
call
orup
method: Accepts a requiredpayload
and an optionalopts
argument. This method processes the action and returns aRdux::Result
.down
method: Accepts the deserializeddown_payload
which is one of arguments of theRdux::Result
struct
returned by theup
method on success and saved in DB.down
method can optionally accept the 2nd argument (Hash) which:nested
key contains nestedRdux::Action
s
See ๐ Dispatching an action section.
Examples:
# app/actions/task/create.rb
class Task
class Create
def up(payload, opts)
user = opts.dig(:ars, :user) || User.find(payload['user_id'])
task = user.tasks.new(payload['task'])
if task.save
Rdux::Result[ok: true, down_payload: { user_id: user.id, task_id: task.id }, val: { task: }]
else
Rdux::Result[false, { errors: task.errors }]
end
end
def down(payload)
Delete.up(payload)
end
end
end
# app/actions/task/delete.rb
class Task
module Delete
def self.up(payload)
user = User.find(payload['user_id'])
task = user.tasks.find(payload['task_id'])
task.destroy
Rdux::Result[true, { task: task.attributes }]
end
end
end
The location that is often used for entities like actions accross code bases is app/services
.
This directory is de facto the bag of random objects.
I'd recomment to place actions inside app/actions
for better organization and consistency.
Actions are consistent in terms of structure, input and output data.
They are good canditates to create a new layer in Rails apps.
Structure:
.
โโโ app/actions/
โโโ activity/
โ โโโ common/
โ โ โโโ fetch.rb
โ โโโ create.rb
โ โโโ stop.rb
โ โโโ switch.rb
โโโ task/
โ โโโ create.rb
โ โโโ delete.rb
โโโ misc/
โโโ create_attachment.rb
The dedicated page about actions contains more arguments in favor of actions.
Definition:
module Rdux
Result = Struct.new(:ok, :down_payload, :val, :up_result, :save, :after_save, :nested, :action) do
def val
self[:val] || down_payload
end
def save_failed?
ok == false && save
end
end
end
Arguments:
ok
(Boolean): Indicates whether the action was successful. Iftrue
, theRdux::Action
is persisted in the database.down_payload
(Hash): Passed to the action performerโsdown
method during reversion (down
method is called onRdux::Action
). It does not have to be defined if an action performer does not implement thedown
method.down_payload
is saved in the DB.val
(Hash): Contains different returned data thandown_payload
.up_result
(Hash): Stores data related to the actionโs execution, such as created record IDs, DB changes, responses from 3rd parties, etc.save
(Boolean): Iftrue
andok
isfalse
, the action is saved as aRdux::FailedAction
.after_save
(Proc): Called just before thedispatch
method returns theRdux::Result
withRdux::Action
orRdux::FailedAction
as an argument.nested
(Array ofRdux::Result
):Rdux::Action
can be connected with otherrdux_actions
.Rdux::FailedAction
can be connected with otherrdux_actions
andrdux_failed_actions
. To establish an association, a given action mustRdux.dispatch
other actions in theup
orcall
method and add the returned by thedispatch
value (Rdux::Result
) to the:nested
arrayaction
: Rdux assignsRdux::Action
orRdux::FailedAction
to this argument
To revert an action, call the down
method on the persisted in DB Rdux::Action
instance.
The Rdux::Action
must have a down_payload
defined and the action (action performer) must have the down
method implemented.
The down_at
attribute is set upon successful reversion. Actions cannot be reverted if there are newer, unreverted actions in the same stream (if defined) or in general. See meta
in ๐ Dispatching an action section.
payload = {
task: { 'name' => 'Foo bar baz' },
user_id: 159163583
}
res = Rdux.dispatch(Task::Create, payload)
res.action
# #<Rdux::Action:0x000000011c4d8e98
# id: 1,
# name: "Task::Create",
# up_payload: {"task"=>{"name"=>"Foo bar baz"}, "user_id"=>159163583},
# down_payload: {"task_id"=>207620945},
# down_at: nil,
# up_payload_sanitized: false,
# up_result: nil,
# meta: {},
# stream_hash: nil,
# rdux_action_id: nil,
# rdux_failed_action_id: nil,
# created_at: Fri, 28 Jun 2024 21:35:36.838898000 UTC +00:00,
# updated_at: Fri, 28 Jun 2024 21:35:36.839728000 UTC +00:00>>
res.action.down
When Rdux.perform
is called, the up_payload
is sanitized using Rails.application.config.filter_parameters
before being saved to the database.
The action performerโs up
or call
method receives the unsanitized version.
Note that once the up_payload
is sanitized, the Rdux::Action
cannot be retried by calling the #up
method.
Most likely, it won't be necessary to save a Rdux::Action
for every request a Rails app receives.
The suggested approach is to save Rdux::Action
s for Create, Update, and Delete (CUD) operations.
This approach organically creates a new layer - queries in addition to actions.
Thus, it is required to call Rdux.perform
only for actions.
One approach is to create a perform
method that invokes either Rdux.perform
or a query, depending on the presence of action
or query
keywords.
This method can also handle setting meta
attributes, performing parameter validation, and more.
Example:
class TasksController < ApiController
def show
perform(
query: Task::Show,
payload: { id: params[:id] }
)
end
def create
perform(
action: Task::Create,
payload: create_task_params
)
end
end
Depending on your use case, itโs recommended to create indices, especially when using PostgreSQL and querying JSONB columns.
Both Rdux::Action
and Rdux::FailedAction
are standard ActiveRecord models.
You can inherit from them and extend.
Example:
class Action < Rdux::Action
include Actionable
end
Rdux creates a Rdux::FailedAction
when an exception is raised during the execution of an action.
The up_payload
is retained, but having only the input data is often not enough to retry an action.
It is crucial to capture data obtained during the actionโs execution, up until the exception occurred.
This can be done by using opts[:up_result]
to store all necessary data incrementally.
The assigned data will then be available as the up_result
argument in the Rdux::FailedAction
.
Example:
class CreditCard
class Charge
class << self
def call(payload, opts)
create_res = create(payload.slice('user_id', 'credit_card'), opts.slice(:user))
return create_res unless create_res.ok
opts[:up_result] = { credit_card_create_action_id: create_res.action.id }
charge_id = PaymentGateway.charge(create_res.val[:credit_card].token, payload['amount'])[:id]
if charge_id.nil?
Rdux::Result[ok: false, val: { errors: { base: 'Invalid credit card' } }, save: true,
nested: [create_res]]
else
Rdux::Result[ok: true, val: { charge_id: }, nested: [create_res]]
end
end
private
def create(payload, opts)
res = Rdux.perform(Create, payload, opts)
return res if res.ok
Rdux::Result[ok: false, val: { errors: res.val[:errors] }, save: true, nested: [res]]
end
end
end
end
$ cd test/dummy
$ DB=all bin/rails db:create
$ DB=all bin/rails db:prepare
$ cd ../..
$ DB=postgres bin/rails test
$ DB=sqlite bin/rails test
The gem is available as open source under the terms of the MIT License.
Zbigniew Humeniuk from Art of Code