finalizers 0.0.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 822593a5b0219e62e989781c398b5f943763df9ca62bbb284096294a78eab3f9
4
- data.tar.gz: 6fbce423173fbaf892d25c737527b15bc476a00d99f4d741c3dac6929143459d
3
+ metadata.gz: 02dec5224fb743376e9f9af2f17a522512a728dc1b80e55d002b01a81784649f
4
+ data.tar.gz: 231c643d2047c478961ff7679b04fd60d6372954ed3ca6491eaa0cefe0b03911
5
5
  SHA512:
6
- metadata.gz: dd753926bcd218a6e497c6b00215c49289e73d5120742edd6d7c8bd2328e715cf4f759d50a76053036ce137e8ab1dd0b3512a9dc164e324e879116a27dc9ada7
7
- data.tar.gz: 1c8e5e4fa69fddf9f1105669370ed959bf716a39d09da2ae425cb4ff20a2593bb49e8bc3f6b6ccb585e86ea35e6016b07575930a96ba0325669cdd71c9b3e8b5
6
+ metadata.gz: 583b9dd07abcf74ff42b431a5ffd85233d5ebbe337af5d9ecaef5c7043ec74786b1642ed8ce2260cb189a8aabd1faf3d247e83de2012fefb8a0e711060366a78
7
+ data.tar.gz: 8beeefa591f84c3672750133935279add71e40d01b34c93862bf51d03cd8d56a140862fb018ace9fea808254ac11b252cbbe6d1172b3b711643859c70cb4b095
@@ -1,4 +1,4 @@
1
- Copyright thomas morgan
1
+ Copyright 2023 thomas morgan
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,28 +1,177 @@
1
1
  # Finalizers
2
- Short description and motivation.
3
2
 
4
- ## Usage
5
- How to use my plugin.
3
+ Add finalizers to your ActiveRecord models. Useful for cleaning up child dependencies in the database as well as associated external resources (APIs, etc).
6
4
 
7
- ## Installation
8
- Add this line to your application's Gemfile:
5
+ * Finalizers and the eventual `destroy` run in background jobs, keeping controllers quick and responsive
6
+ * Finalizers may cleanup other database records, remote APIs, or anything else relevant
7
+ * Finalizers may also confirm any arbitrary dependency, making them extremly flexible
8
+ * Quickly define database-based dependencies with `erase_dependents`
9
+ * Supports cascading deletes
10
+ * Replaces `has_many ... dependent: :async`
11
+ * Background jobs are fully retryable, easily handling delays in satisfying finalizer dependencies and checks
12
+ * Dynamically determine when models shouldn't be deleted at all using `erasable?`
13
+ * Easily check if erasable and delete (erase) in controllers with `safe_erase`
14
+
15
+
16
+
17
+ ## Basics and Usage
18
+
19
+ Each model used with Finalizers requires a `state` string field.
20
+
21
+ 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.
22
+
23
+ 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).
24
+
25
+
26
+ #### Installation
27
+
28
+ As always, add to your Gemfile and run `bundle install` (or however you like to do such things):
9
29
 
10
30
  ```ruby
11
31
  gem "finalizers"
12
32
  ```
13
33
 
14
- And then execute:
15
34
  ```bash
16
35
  $ bundle
17
36
  ```
18
37
 
19
- Or install it yourself as:
20
- ```bash
21
- $ gem install finalizers
38
+
39
+ #### Models
40
+
41
+ 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.
42
+
43
+ Then, add `include Finalizers::Model` to the model and define an `erasable?` method.
44
+
45
+ To automatically cascade erase operations onto child classes (ie: `has_one` or `has_many`), use `erase_dependents`.
46
+
47
+ To add custom finalizers, use `add_finalizer`.
48
+
49
+ ---
50
+
51
+ Finalizers add new `erase` and `erase!` methods to your model. You should generally use these instead of `destroy`.
52
+
53
+ `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.
54
+
55
+ For controllers and all other 'normal' actions, use `erase`, `erase!`, or `safe_erase`. `safe_erase` is designed especially for controllers. See below.
56
+
57
+ ```ruby
58
+ class Vehicle < ApplicationRecord
59
+ # Must have `state` attribute
60
+ # May have `state_at` attribute. If present, will be updated when `state` is updated.
61
+ # May have `delete_at` attribute. If present, will be cleared when `erase` is called.
62
+ include Finalizers::Model
63
+
64
+ add_finalizer :delete_from_remote
65
+ # In addition to ensuring dependents are destroyed (see erase_dependents below),
66
+ # additional work can be required to complete before this record is destroyed.
67
+ # This is especially useful for cleaning up data in another system, but isn't
68
+ # limited to that.
69
+ # See `#delete_from_remote` below for more discussion.
70
+ add_finalizer do
71
+ # alternate syntax to define work inline if preferred
72
+ raise RetryJobError, "#{self.class.name} #{id} still running" if running?
73
+ end
74
+
75
+ has_many :wheels
76
+ # Hint: to further protect against accidental use of #destroy (and bypassing your
77
+ # finalizers), add a foreign key restriction to your schema and then add
78
+ # `:restrict_with_exception` to the has_many definition:
79
+ # has_many :wheels, dependent: :restrict_with_exception
80
+ erase_dependents :wheels
81
+ # This does 2 things:
82
+ # a) When Vehicle is erased (state := 'deleted'), causes all child Wheels to be
83
+ # erased too. (And, if Wheel has_one :tire, this will cascade as well.)
84
+ # b) Adds a finalizer to verify that the associated children are destroyed
85
+ # before proceeding. That means that children will always have access to the
86
+ # parent while performing their own finalization.
87
+ # Note: any dependent classes here must also include Finalizers::Model.
88
+
89
+ def erasable?
90
+ true # Allow `safe_erase` to proceed
91
+ # false # Prevent `safe_erase` from proceeding
92
+ end
93
+ # This only affects `safe_erase`. Using `erase` or `erase~` will work regardless.
94
+ # Returning false is particularly useful if an object shouldn't be allowed to be
95
+ # erased because it's in use, is a global object, etc.
96
+
97
+ # Finalizer callback, as configured above.
98
+ def delete_from_remote
99
+ # If another finalizer fails (including erase_dependents), this finalizer may be
100
+ # called more than once so it should be idempotent.
101
+ # You may use (and update) a tracking field (`remote_uuid` here), or may simply
102
+ # repeat the operation.
103
+ if remote_uuid
104
+ RemoteService.delete id: remote_uuid
105
+ update_columns remote_uuid: nil
106
+ end
107
+
108
+ # Exceptions will cause the finalizer to fail and be retried, so they should
109
+ # usually be passed through. You can raise RetryJobError if another exception
110
+ # isn't already in play.
111
+ end
112
+
113
+ end
22
114
  ```
23
115
 
116
+
117
+ #### Controllers
118
+
119
+ 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'`.
120
+
121
+ ```ruby
122
+ def destroy
123
+ if @model.safe_erase
124
+ render @model, notice: 'Resource deleted.'
125
+ else
126
+ render 'errors', locals: {obj: @model}, status: 422
127
+ # however you normally render errors
128
+ end
129
+ end
130
+ ```
131
+
132
+
133
+ #### Error reporting
134
+
135
+ Finalizers uses `RetryJobError` internally to help manage flow. It is recommended to exclude it from any exception reporting tool (Honeybadger, Sentry, etc).
136
+
137
+
138
+
139
+ ## Advanced usage
140
+
141
+ #### Overriding the default EraserJob
142
+
143
+ Just create your own version of the job in your app. Zeitwerk should prefer the app's version over the gem's.
144
+
145
+ Be sure to keep the existing signature for `perform`:
146
+ ```ruby
147
+ def perform(obj)
148
+ end
149
+ ```
150
+
151
+ #### Extending the default EraserJob
152
+
153
+ Like overriding, create your own version of the job and require the original job before reopening it:
154
+ ```ruby
155
+ require "#{Finalizers::Engine.root}/app/jobs/eraser_job"
156
+ class EraserJob
157
+ # add extensions here
158
+ end
159
+ ```
160
+
161
+
162
+
163
+ ## History and Compatibility
164
+
165
+ Extracted from production code.
166
+
167
+ Tested w/Rails 7.x and GoodJob 3.x.
168
+
169
+
170
+
24
171
  ## Contributing
25
- Contribution directions go here.
172
+ Pull requests welcomed. If unsure whether a proposed addition is in scope, feel free to open an Issue for discussion (not required though).
173
+
174
+
26
175
 
27
176
  ## License
28
177
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -6,3 +6,14 @@ load "rails/tasks/engine.rake"
6
6
  load "rails/tasks/statistics.rake"
7
7
 
8
8
  require "bundler/gem_tasks"
9
+
10
+
11
+ require "rake/testtask"
12
+
13
+ Rake::TestTask.new(:test) do |t|
14
+ t.libs << 'test'
15
+ t.pattern = 'test/**/*_test.rb'
16
+ t.verbose = false
17
+ end
18
+
19
+ task default: :test
@@ -0,0 +1,15 @@
1
+ class EraserJob < Finalizers::ApplicationJob
2
+ queue_with_priority 10
3
+
4
+ retry_on Exception, wait: 20.seconds, jitter: 15.seconds, attempts: :unlimited, priority: 20
5
+
6
+ def perform(obj)
7
+ if obj.state == 'deleted'
8
+ obj.finalize_and_destroy!
9
+ end
10
+ rescue RetryJobError => ex
11
+ logger.warn "#{ex.message} (attempt=#{executions})"
12
+ raise
13
+ end
14
+
15
+ end
@@ -0,0 +1,11 @@
1
+ module Finalizers
2
+ class ApplicationJob < ActiveJob::Base
3
+
4
+ # Automatically retry jobs that encountered a deadlock
5
+ retry_on ActiveRecord::Deadlocked, wait: 10.seconds, attempts: :unlimited, jitter: 3.seconds
6
+
7
+ # Most jobs are safe to ignore if the underlying records are no longer available
8
+ discard_on ActiveJob::DeserializationError
9
+
10
+ end
11
+ end
@@ -0,0 +1,2 @@
1
+ class RetryJobError < RuntimeError
2
+ end
@@ -0,0 +1,192 @@
1
+ # usage:
2
+ # class Book # mongoid or activerecord
3
+ # include Concerns::Finalizers
4
+ # has_many :editions
5
+ # # may add dependent: :restrict_with_exception to help ensure proper operation
6
+ # # of erase/finalization. similarly, may still use dependent: :destroy to
7
+ # # bypass erase/finalization system, but this is discouraged except perhaps in
8
+ # # tests [and delete() may be a better choice yet].
9
+ # erase_dependents :editions # replaces dependent: :destroy in has_many/etc
10
+ # # this should be used for dependents that themselves should also erase in the
11
+ # # background (and optionally define finalizers of their own).
12
+ # # will delay erasing the present object until finalizers for dependents have
13
+ # # completed.
14
+ #
15
+ # add_finalizer :do_something
16
+ # add_finalizer do
17
+ # do_something_else || throw(:abort)
18
+ # # alt: on error, raise RetryJobError instead
19
+ # end
20
+ # end
21
+ # b = Book.create
22
+ # b.erase # alt: b.update state: 'deleted'
23
+ # b.state # => 'deleted'
24
+ # b.editions.first.state # => 'deleted
25
+ #
26
+ # the background job will then run finalizers for each Book and Edition being erased.
27
+ # like Rails' standard callbacks, if a finalizer fails, `throw(:abort)`,
28
+ # `raise RetryJobError, 'specific message'`, or raise any other exception.
29
+ # RetryJobError and throw(:abort) are excluded from sentry exception notifications.
30
+ # other exceptions will pass through as normal.
31
+ #
32
+ # requires a `state` field on the model.
33
+ # will call before/after destroy hooks when the object is finally destroyed, after
34
+ # completing finalizers.
35
+ #
36
+ # finalizers only run after verifying that dependent objects have been erased (as
37
+ # instructed by calling `erase_dependents`). this means that parent objects are not
38
+ # finalized or destroyed until all children are gone. this ensures child objects
39
+ # still have access to a functioning (undestroyed) parent to complete their own
40
+ # finalizers.
41
+ # the lifecycle looks like this:
42
+ # check dependents for not-yet-finalized objects
43
+ # run finalizers
44
+ # run before_destroy callbacks
45
+ # destroy self
46
+ # run after_destroy callbacks
47
+ #
48
+ # finalizers should be idempotent as they may run more than once in the event of a
49
+ # failure and subsequent retry. they may persist changes to the model to help manage
50
+ # idempotence.
51
+ #
52
+ # note that erase() simply updates :state and will execute normal save and update
53
+ # callbacks.
54
+ # like destroy(), erase() does not run validations. to conditionally trigger an
55
+ # erase, use update(state: 'deleted') instead, which will not bypass validations.
56
+
57
+
58
+ module Finalizers::Model
59
+ extend ActiveSupport::Concern
60
+
61
+ included do
62
+ define_model_callbacks :finalize, only: [:before]
63
+ class << self
64
+ alias_method :add_finalizer, :before_finalize
65
+ end
66
+
67
+ after_save do
68
+ if state == 'deleted' && state_previously_was != 'deleted'
69
+ EraserJob.perform_later self
70
+ end
71
+ end
72
+
73
+ scope :not_deleted, ->{ where.not(state: 'deleted') }
74
+ end
75
+
76
+
77
+ module ClassMethods
78
+
79
+ # if child models don't include Finalizers::Model, but are self-deleting (perhaps they
80
+ # represent an in-progress work task and are deleted when finished), then use
81
+ # wait_for_no_dependents to defer destroying the current model until the children are gone.
82
+ # if they are not self-deleting, use standard options like dependent: :destroy or :delete_all.
83
+ # finally, if children do include Finalizers::Model, use erase_dependents instead.
84
+ def wait_for_no_dependents(*assoc_list, erase_if_found: false)
85
+ add_finalizer prepend: true do
86
+ assoc_list.each do |assoc|
87
+ proxy = send(assoc)
88
+ # has_many's :dependent checks use .size, so match that here
89
+ # .size uses the cache_counter column, if available, else queries the db
90
+ if proxy.respond_to?(:size)
91
+ count = proxy.size
92
+ _perform_erase_dependents assoc if erase_if_found && count > 0 && proxy.not_deleted.any?
93
+ elsif proxy
94
+ count = 1
95
+ _perform_erase_dependents assoc if erase_if_found && !proxy.deleted?
96
+ else
97
+ count = 0
98
+ end
99
+ if count > 0
100
+ raise RetryJobError, "#{self.class.name} #{id} still has #{count} dependent #{assoc}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # if child models include Finalizers::Model, then use erase_dependents to perform a cascading
107
+ # erase. use this instead of has_many or has_one's depdendent: :destroy (etc).
108
+ def erase_dependents(*assoc_list)
109
+ wait_for_no_dependents(*assoc_list, erase_if_found: true)
110
+ before_update do
111
+ if state == 'deleted' && state_was != 'deleted'
112
+ assoc_list.each do |assoc|
113
+ _perform_erase_dependents assoc
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ end
120
+
121
+
122
+ def deleted? ; state=='deleted' ; end
123
+
124
+ def destroy(force: false)
125
+ raise 'Called destroy() directly instead of using erase()' unless force
126
+ super()
127
+ end
128
+ def destroy!(force: false)
129
+ raise 'Called destroy!() directly instead of using erase!()' unless force
130
+ destroy(force: true) || _raise_record_not_destroyed
131
+ end
132
+
133
+ # should run callbacks, but not validations
134
+ # intent is to parallel destroy()'s behavior
135
+ def erase
136
+ self.state = 'deleted'
137
+ self.state_at = Time.current if respond_to?(:state_at=) && state_changed?
138
+ self.delete_at = nil if respond_to?(:delete_at=)
139
+ save validate: false
140
+ end
141
+ def erase!
142
+ self.state = 'deleted'
143
+ self.state_at = Time.current if respond_to?(:state_at=) && state_changed?
144
+ self.delete_at = nil if respond_to?(:delete_at=)
145
+ save! validate: false
146
+ end
147
+
148
+ # must define on model
149
+ # def erasable?
150
+ # ...
151
+ # end
152
+
153
+ def safe_erase
154
+ if erasable?
155
+ erase
156
+ else
157
+ errors.add :base, "#{self.class.model_name.human} in use"
158
+ false
159
+ end
160
+ end
161
+
162
+
163
+ # called by EraserJob
164
+ # may call directly for testing
165
+ def finalize_and_destroy!
166
+ # finalizers execute outside of the destroy transaction (and callback sequence).
167
+ # this intentionally allows finalizers to do whatever action and /persist/ that finalized state
168
+ # so if a later finalizer aborts, the previous ones don't have to repeat themselves.
169
+ # finalizers should still be idempotent though, as a finalizer could re-run due to an error
170
+ # persisting that state, server failure, etc.
171
+ run_callbacks :finalize do
172
+ destroy force: true
173
+ end
174
+ raise RetryJobError, "#{self.class.name} #{id} finalizers did not complete" unless destroyed?
175
+ self
176
+ end
177
+
178
+
179
+ private
180
+
181
+ def _perform_erase_dependents(assoc)
182
+ # both activerecord and mongoid use non-! variants for their :dependent
183
+ # implementations, silently ignoring failures. match that behavior here.
184
+ proxy = send(assoc)
185
+ if proxy.respond_to?(:each)
186
+ proxy.not_deleted.each(&:erase)
187
+ elsif proxy
188
+ proxy.erase unless proxy.deleted?
189
+ end
190
+ end
191
+
192
+ end
@@ -1,3 +1,3 @@
1
1
  module Finalizers
2
- VERSION = "0.0.1"
2
+ VERSION = '0.5.0'
3
3
  end
data/lib/finalizers.rb CHANGED
@@ -1,6 +1,6 @@
1
- require "finalizers/version"
2
- require "finalizers/engine"
3
-
4
1
  module Finalizers
5
- # Your code goes here...
2
+ end
3
+
4
+ %w(engine version).each do |f|
5
+ require "finalizers/#{f}"
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: finalizers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thomas morgan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-11-15 00:00:00.000000000 Z
11
+ date: 2023-12-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -16,34 +16,54 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '7.1'
19
+ version: '7.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '7.1'
27
- description: Finalizers.
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rescue_like_a_pro
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1'
41
+ description: Adds finalizers to ActiveRecord models to clean up both database child
42
+ dependencies and external resources (APIs, etc). Finalizers run in background jobs
43
+ and are fully retryable.
28
44
  email:
29
45
  - tm@iprog.com
30
46
  executables: []
31
47
  extensions: []
32
48
  extra_rdoc_files: []
33
49
  files:
34
- - MIT-LICENSE
50
+ - LICENSE.txt
35
51
  - README.md
36
52
  - Rakefile
37
- - app/assets/config/finalizers_manifest.js
38
- - config/routes.rb
53
+ - app/jobs/eraser_job.rb
54
+ - app/jobs/finalizers/application_job.rb
55
+ - app/jobs/retry_job_error.rb
56
+ - app/models/finalizers/model.rb
39
57
  - lib/finalizers.rb
40
58
  - lib/finalizers/engine.rb
41
59
  - lib/finalizers/version.rb
42
60
  - lib/tasks/finalizers_tasks.rake
43
- homepage:
61
+ homepage: https://github.com/zarqman/finalizers
44
62
  licenses:
45
63
  - MIT
46
- metadata: {}
64
+ metadata:
65
+ homepage_uri: https://github.com/zarqman/finalizers
66
+ source_code_uri: https://github.com/zarqman/finalizers
47
67
  post_install_message:
48
68
  rdoc_options: []
49
69
  require_paths:
@@ -62,5 +82,5 @@ requirements: []
62
82
  rubygems_version: 3.4.10
63
83
  signing_key:
64
84
  specification_version: 4
65
- summary: Finalizers
85
+ summary: Adds finalizers to ActiveRecord models
66
86
  test_files: []
File without changes
data/config/routes.rb DELETED
@@ -1,2 +0,0 @@
1
- Rails.application.routes.draw do
2
- end