Finalizers
Add finalizers to your ActiveRecord models. Useful for cleaning up child dependencies in the database as well as associated external resources (APIs, etc).
- Finalizers and the eventual
destroy
run in background jobs, keeping controllers quick and responsive - Finalizers may cleanup other database records, remote APIs, or anything else relevant
- Finalizers may also confirm any arbitrary dependency, making them extremly flexible
- Quickly define database-based dependencies with
erase_dependents
- Supports cascading deletes
- Replaces
has_many ... dependent: :async
- Background jobs are fully retryable, easily handling delays in satisfying finalizer dependencies and checks
- Dynamically determine when models shouldn't be deleted at all using
erasable?
- Easily check if erasable and delete (erase) in controllers with
safe_erase
Basics and Usage
Each model used with Finalizers requires a state
string field.
Finalizers is also aware and accommodative of state_at
(when state
was last changed) and delete_at
(for scheduling a future delete), but expects those to be implemented separately.
A quick heads up: Finalizers depends on rescue_like_a_pro
which changes how retry_on
and discard_on
are processed for all ActiveJob children. rescue_like_a_pro
changes ActiveJob to handle exceptions based on specificity instead of last defintion, which most will find more intuitive. For basic usage, likely nothing will change. For advanced exception handling, it may warrant a review of your Job classes (which can often be simplified as a result).
Installation
As always, add to your Gemfile and run bundle install
(or however you like to do such things):
gem "finalizers"
$ bundle
Models
Add the required state
field using a migration. It just needs to be a simple string long enough to hold "deleted"
and any other values you wish to you.
Then, add include Finalizers::Model
to the model and define an erasable?
method.
To automatically cascade erase operations onto child classes (ie: has_one
or has_many
), use erase_dependents
.
To add custom finalizers, use add_finalizer
.
Finalizers add new erase
and erase!
methods to your model. You should generally use these instead of destroy
.
destroy
and destroy!
continue to exist and will still destroy immediately, without running finalizers or handling dependent records. To prevent accidentally calling them and thus bypassing your finalizers, the :force
argument must be added: destroy(force: true)
. This is often still useful in tests.
For controllers and all other 'normal' actions, use erase
, erase!
, or safe_erase
. safe_erase
is designed especially for controllers. See below.
class Vehicle < ApplicationRecord
include Finalizers::Model
add_finalizer :delete_from_remote
add_finalizer do
raise RetryJobError, "#{self.class.name} #{id} still running" if running?
end
has_many :wheels
erase_dependents :wheels
def erasable?
true
end
def delete_from_remote
if remote_uuid
RemoteService.delete id: remote_uuid
update_columns remote_uuid: nil
end
end
end
Controllers
In SomeController#destroy
, use safe_erase
instead of destroy
. safe_erase
returns a boolean and will add an error message when false, so it allows making #destroy
work like #update
. Optionally, you may erase via #update
by setting @model.state = 'deleted'
.
def destroy
if @model.safe_erase
render @model, notice: 'Resource deleted.'
else
render 'errors', locals: {obj: @model}, status: 422
end
end
Error reporting
Finalizers uses RetryJobError
internally to help manage flow. It is recommended to exclude it from any exception reporting tool (Honeybadger, Sentry, etc).
Advanced usage
Overriding the default EraserJob
Just create your own version of the job in your app. Zeitwerk should prefer the app's version over the gem's.
Be sure to keep the existing signature for perform
:
def perform(obj)
end
Extending the default EraserJob
Like overriding, create your own version of the job and require the original job before reopening it:
load "#{Finalizers::Engine.root}/app/jobs/eraser_job.rb"
class EraserJob
end
History and Compatibility
Extracted from production code.
Tested w/Rails 7.x, 8.x; GoodJob 3.x; and SolidQueue 1.x.
Contributing
Pull requests welcomed. If unsure whether a proposed addition is in scope, feel free to open an Issue for discussion (not required though).
License
The gem is available as open source under the terms of the MIT License.