acidic_job 1.0.0.pre29 β†’ 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +13 -0
  3. data/.github/workflows/main.yml +12 -15
  4. data/.gitignore +3 -1
  5. data/.rubocop.yml +50 -5
  6. data/.ruby-version +1 -0
  7. data/Gemfile.lock +114 -198
  8. data/README.md +163 -246
  9. data/TODO +77 -0
  10. data/acidic_job.gemspec +8 -10
  11. data/app/models/acidic_job/entry.rb +19 -0
  12. data/app/models/acidic_job/execution.rb +50 -0
  13. data/app/models/acidic_job/record.rb +11 -0
  14. data/app/models/acidic_job/value.rb +7 -0
  15. data/bin/console +5 -2
  16. data/bin/test_all +26 -0
  17. data/gemfiles/rails_7.0.gemfile +4 -1
  18. data/gemfiles/rails_7.1.gemfile +11 -0
  19. data/gemfiles/rails_7.2.gemfile +11 -0
  20. data/gemfiles/rails_8.0.gemfile +11 -0
  21. data/lib/acidic_job/arguments.rb +31 -0
  22. data/lib/acidic_job/builder.rb +29 -0
  23. data/lib/acidic_job/context.rb +26 -0
  24. data/lib/acidic_job/engine.rb +46 -0
  25. data/lib/acidic_job/errors.rb +91 -12
  26. data/lib/acidic_job/log_subscriber.rb +50 -0
  27. data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
  28. data/lib/acidic_job/serializers/job_serializer.rb +27 -0
  29. data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
  30. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  31. data/lib/acidic_job/testing.rb +8 -12
  32. data/lib/acidic_job/version.rb +1 -1
  33. data/lib/acidic_job/workflow.rb +185 -0
  34. data/lib/acidic_job.rb +15 -284
  35. data/lib/generators/acidic_job/install_generator.rb +3 -3
  36. data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
  37. metadata +45 -115
  38. data/.ruby_version +0 -1
  39. data/.tool-versions +0 -1
  40. data/gemfiles/rails_6.1.gemfile +0 -8
  41. data/lib/acidic_job/awaiting.rb +0 -102
  42. data/lib/acidic_job/extensions/action_mailer.rb +0 -29
  43. data/lib/acidic_job/extensions/active_job.rb +0 -40
  44. data/lib/acidic_job/extensions/noticed.rb +0 -54
  45. data/lib/acidic_job/extensions/sidekiq.rb +0 -111
  46. data/lib/acidic_job/finished_point.rb +0 -16
  47. data/lib/acidic_job/idempotency_key.rb +0 -82
  48. data/lib/acidic_job/perform_wrapper.rb +0 -22
  49. data/lib/acidic_job/recovery_point.rb +0 -18
  50. data/lib/acidic_job/rspec_configuration.rb +0 -31
  51. data/lib/acidic_job/run.rb +0 -100
  52. data/lib/acidic_job/serializer.rb +0 -163
  53. data/lib/acidic_job/staging.rb +0 -38
  54. data/lib/acidic_job/step.rb +0 -104
  55. data/lib/acidic_job/test_case.rb +0 -9
  56. data/lib/acidic_job/upgrade_service.rb +0 -118
  57. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  58. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
  59. data/lib/generators/acidic_job/templates/drop_acidic_job_keys_migration.rb.erb +0 -27
data/README.md CHANGED
@@ -1,106 +1,92 @@
1
- # AcidicJob
1
+ # πŸ§ͺ Acidic Job
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/acidic_job.svg)](https://badge.fury.io/rb/acidic_job)
4
- ![main workflow](https://github.com/fractaledmind/acidic_job/actions/workflows/main.yml/badge.svg)
3
+ [![Gem Version](https://badge.fury.io/rb/acidic_job.svg)](https://rubygems.org/gems/acidic_job)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/acidic_job)](https://rubygems.org/gems/acidic_job)
5
+ ![Tests](https://github.com/fractaledmind/acidic_job/actions/workflows/main.yml/badge.svg)
6
+ ![Coverage](https://img.shields.io/badge/code%20coverage-98%25-success)
7
+ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e0df63f7a6f141d4aecc3c477314fdb2)](https://www.codacy.com/gh/fractaledmind/acidic_job/dashboard?utm_source=github.com&utm_medium=referral&utm_content=fractaledmind/acidic_job&utm_campaign=Badge_Grade)
8
+ [![Sponsors](https://img.shields.io/github/sponsors/fractaledmind?color=eb4aaa&logo=GitHub%20Sponsors)](https://github.com/sponsors/fractaledmind)
9
+ [![Twitter Follow](https://img.shields.io/twitter/url?label=%40fractaledmind&style=social&url=https%3A%2F%2Ftwitter.com%2Ffractaledmind)](https://twitter.com/fractaledmind)
5
10
 
6
- ### Idempotent operations for Rails apps (for ActiveJob or Sidekiq)
11
+ > [!WARNING]
12
+ > This is the README for the _new_ release candidate of v1, which is a major refactor from the [previous release candidate of v1](https://github.com/fractaledmind/acidic_job/tree/v1.0.0.pre29). If you are looking for the stable release, please refer to the [v0.9.0 README](https://github.com/fractaledmind/acidic_job/tree/v0.9.0).
7
13
 
8
- At the conceptual heart of basically any software are "operations"β€”the discrete actions the software performs. Rails provides a powerful abstraction layer for building operations in the form of `ActiveJob`, or we Rubyists can use the tried and true power of pure `Sidekiq`. With either we can easily trigger from other Ruby code throughout our Rails application (controller actions, model methods, model callbacks, etc.); we can run operations both synchronously (blocking execution and then returning its response to the caller) and asychronously (non-blocking and the caller doesn't know its response); and we can also retry a specific operation if needed seamlessly.
9
14
 
10
- However, in order to ensure that our operational jobs are _robust_, we need to ensure that they are properly [idempotent and transactional](https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional). As stated in the [GitLab Sidekiq Style Guide](https://docs.gitlab.com/ee/development/sidekiq_style_guide.html#idempotent-jobs):
15
+ ## Durable execution workflows for Active Job
11
16
 
12
- >As a general rule, a worker can be considered idempotent if:
13
- > * It can safely run multiple times with the same arguments.
14
- > * Application side-effects are expected to happen only once (or side-effects of a second run do not have an effect).
17
+ Rails applications today frequently need to coordinate complex multi-step operations across external services, databases, and systems. While Active Job provides eventual consistency guarantees, it doesn't address the challenges of managing stateful, long-running operations that must be resilient to failures, timeouts, and partial completions. `AcidicJob` enhances Active Job with durable execution workflows that automatically track state and resiliently handle retries, while providing you the tools to ensure your operations are truly idempotent through careful state management and IO awareness.
15
18
 
16
- This is, of course, far easier said than done. Thus, `AcidicJob`.
19
+ With AcidicJob, you can write reliable and repeatable multi-step distributed operations that are Atomic βš›οΈ, Consistent πŸ€–, Isolated πŸ•΄πŸΌ, and Durable ⛰️.
17
20
 
18
- `AcidicJob` provides a framework to help you make your operational jobs atomic βš›οΈ, consistent πŸ€–, isolated πŸ•΄πŸΌ, and durable ⛰️. Its conceptual framework is directly inspired by a truly wonderful loosely collected series of articles written by Brandur Leach, which together lay out core techniques and principles required to make an HTTP API properly ACIDic:
19
-
20
- 1. https://brandur.org/acid
21
- 2. https://brandur.org/http-transactions
22
- 3. https://brandur.org/job-drain
23
- 4. https://brandur.org/idempotency-keys
24
-
25
- `AcidicJob` brings these techniques and principles into the world of a standard Rails application.
26
21
 
27
22
  ## Installation
28
23
 
29
- Add this line to your application's Gemfile:
24
+ Install the gem and add to the application's Gemfile by executing:
30
25
 
31
- ```ruby
32
- gem 'acidic_job'
26
+ ```sh
27
+ bundle add acidic_job
33
28
  ```
34
29
 
35
- And then execute:
36
-
37
- $ bundle install
30
+ If `bundler` is not being used to manage dependencies, install the gem by executing:
38
31
 
39
- Or simply execute to install the gem yourself:
40
-
41
- $ bundle add acidic_job
32
+ ```sh
33
+ gem install acidic_job
34
+ ```
42
35
 
43
- Then, use the following command to copy over the `AcidicJob::Run` migration file.
36
+ After installing the gem, run the installer:
44
37
 
45
- ```
38
+ ```sh
46
39
  rails generate acidic_job:install
47
40
  ```
48
41
 
42
+ The installer will create a migration file at `db/migrate` to setup the tables that the gem requires.
43
+
44
+
49
45
  ## Usage
50
46
 
51
- `AcidicJob` is a concern that you `include` into your base `ApplicationJob`.
47
+ `AcidicJob` provides a simple DSL to define linear workflows within your job. In order to define and execute a workflow within a particular job, simply `include AcidicJob::Workflow`. This will provide the `execute_workflow` method to the job, which takes a `unique_by` keyword argument and a block where you define the steps of the workflow:
52
48
 
53
49
  ```ruby
54
- class ApplicationJob < ActiveJob::Base
55
- include AcidicJob
56
- end
57
- ```
50
+ class Job < ActiveJob::Base
51
+ include AcidicJob::Workflow
58
52
 
59
- This is useful because the module needs to be mixed into any and all jobs that you want to either make acidic or enqueue acidicly.
53
+ def perform(arg)
54
+ @arg = arg
60
55
 
61
- It provides a suite of functionality that empowers you to create complex, robust, and _acidic_ jobs.
56
+ execute_workflow(unique_by: @arg) do |w|
57
+ w.step :step_1, transactional: true
58
+ w.step :step_2
59
+ w.step :step_3
60
+ end
61
+ end
62
62
 
63
- ### TL;DR
63
+ # ...
64
+ end
65
+ ```
64
66
 
65
- #### Key Features
66
67
 
67
- * **Transactional Steps**
68
- break your job into a series of steps, each of which will be run within an acidic database transaction, allowing retries to jump back to the last "recovery point".
69
- * **Steps that Await Jobs**
70
- have workflow steps await other jobs, which will be enqueued and processed independently, and only when they all have finished will the parent job be re-enqueued to continue the workflow
71
- * **Iterable Steps**
72
- define steps that iterate over some collection fully until moving on to the next step
73
- * **Persisted Attributes**
74
- when retrying jobs at later steps, we need to ensure that data created in previous steps is still available to later steps on retry.
75
- * **Transactionally Staged Jobs**
76
- enqueue additional jobs within the acidic transaction safely
77
- * **Custom Idempotency Keys**
78
- use something other than the job ID for the idempotency key of the job run
79
- * **Sidekiq Callbacks**
80
- bring ActiveJob-like callbacks into your pure Sidekiq Workers
81
- * **Run Finished Callbacks**
82
- set callbacks for when a job run finishes fully
68
+ ## Key Features
83
69
 
84
70
 
85
- ### Transactional Steps
71
+ ### Workflow Steps
86
72
 
87
- The first and foundational feature `acidic_job` provides is the `with_acidity` method, which takes a block of transactional step methods (defined via the `step`) method:
73
+ The foundational feature `AcidicJob` provides is the `execute_workflow` method, which takes a block where you define your workflow's step methods:
88
74
 
89
75
  ```ruby
90
- class RideCreateJob < ActiveJob::Base
91
- include AcidicJob
92
-
76
+ class RideCreateJob < AcidicJob::Base
93
77
  def perform(user_id, ride_params)
94
78
  @user = User.find(user_id)
95
79
  @params = ride_params
96
80
 
97
- with_acidity providing: { ride: nil } do
98
- step :create_ride_and_audit_record
99
- step :create_stripe_charge
100
- step :send_receipt
81
+ execute_workflow(unique_by: [@user, @params]) do |workflow|
82
+ workflow.step :create_ride_and_audit_record, transactional: true
83
+ workflow.step :create_stripe_charge
84
+ workflow.step :send_receipt
101
85
  end
102
86
  end
103
87
 
88
+ private
89
+
104
90
  def create_ride_and_audit_record
105
91
  # ...
106
92
  end
@@ -115,256 +101,158 @@ class RideCreateJob < ActiveJob::Base
115
101
  end
116
102
  ```
117
103
 
118
- `with_acidity` takes only the `providing:` named parameter and a block where you define the steps of this operation. `step` simply takes the name of a method available in the job. That's all!
119
-
120
- Now, each execution of this job will find or create an `AcidicJob::Run` record, which we leverage to wrap every step in a database transaction. Moreover, this database record allows `acidic_job` to ensure that if your job fails on step 3, when it retries, it will simply jump right back to trying to execute the method defined for the 3rd step, and won't even execute the first two step methods. This means your step methods only need to be idempotent on failure, not on success, since they will never be run again if they succeed.
121
-
122
-
123
- ### Steps that Await Jobs
124
-
125
- By simply adding the `awaits` option to your step declarations, you can attach any number of additional, asynchronous jobs to your step. This is profoundly powerful, as it means that you can define a workflow where step 2 is started _if and only if_ step 1 succeeds, but step 1 can have 3 different jobs enqueued on 3 different queues, each running in parallel. Once all 3 jobs succeed, `acidic_job` will re-enqueue the parent job and it will move on to step 2. That's right, you can have workers that are _executed in parallel_, **on separate queues**, and _asynchronously_, but are still **blocking**β€”as a groupβ€”the next step in your workflow! This unlocks incredible power and flexibility for defining and structuring complex workflows and operations.
126
-
127
- ```ruby
128
- class RideCreateJob < ActiveJob::Base
129
- include AcidicJob
130
-
131
- def perform(user_id, ride_params)
132
- @user = User.find(user_id)
133
- @params = ride_params
134
-
135
- with_acidity providing: { ride: nil } do
136
- step :create_ride_and_audit_record, awaits: [SomeJob, AnotherJob]
137
- step :create_stripe_charge
138
- step :send_receipt
139
- end
140
- end
141
- end
142
- ```
104
+ The `unique_by` keyword argument is used to define the unique identifier for a particular execution of the workflow. This helps to ensure that the workflow is idempotent, as retries of the job will correctly resume the pre-existing workflow execution. The `unique_by` argument can be anything that `JSON.dump` can handle.
143
105
 
144
- If you need to await a job that takes arguments, you can prepare that job along with its arguments using the `with` class method that `acidic_job` will add to your jobs:
106
+ The block passed to `execute_workflow` is where you define the steps of the workflow. Each step is defined by calling the `step` method on the yielded workflow builder object. The `step` method takes the name of a method in the job that will be executed as part of the workflow. The `transactional` keyword argument can be used to ensure that the step is executed within a database transaction.
145
107
 
146
- ```ruby
147
- class RideCreateJob < ActiveJob::Base
148
- include AcidicJob
108
+ The `step` method is the only method available on the yielded workflow builder object, and it simply takes the name of a method available in the job.
149
109
 
150
- def perform(user_id, ride_params)
151
- @user = User.find(user_id)
152
- @params = ride_params
110
+ > [!IMPORTANT]
111
+ > In order to craft resilient workflows, you need to ensure that each step method wraps a single unit of IO-bound work. You **must not** have a step method that performs multiple IO-bound operations, like writing to your database and calling an external API. Steps should be as granular and self-contained as possible. This allows your own logic to be more durable in case of failures in third-party APIs, network errors, and so on. So, the rule of thumb is to have only one _state mutation_ per step. And this rule of thumb graduates to a hard and fast rule for _foreign state mutations_. You **must** only have **one** foreign state mutation per step, where a foreign state mutation is any operation that writes to a system beyond your own boundaries. This might be creating a charge on Stripe, adding a DNS record, or sending an email.[^1]
153
112
 
154
- with_acidity providing: { ride: nil } do
155
- step :create_ride_and_audit_record, awaits: awaits: [SomeJob.with('argument_1', keyword: 'value'), AnotherJob.with(1, 2, 3, some: 'thing')]
156
- step :create_stripe_charge
157
- step :send_receipt
158
- end
159
- end
160
- end
161
- ```
113
+ [^1]: I first learned this rule from [Brandur Leach](https://twitter.com/brandur) reminds in his post on [Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys).
162
114
 
163
- If your step awaits multiple jobs (e.g. `awaits: [SomeJob, AnotherJob.with('argument_1', keyword: 'value')]`), your top level workflow job will only continue to the next step once **all** of the jobs in your `awaits` array have finished.
115
+ When your job calls `execute_workflow`, you initiate a durable execution workflow. The execution is made durable via the `AcidicJob::Execution` record that is created. This record is used to track the state of the workflow, and to ensure that if a step fails, the job can be retried and the workflow will pick up where it left off. This is a powerful feature that allows you to build resilient workflows that can handle failures gracefully, because if your job fails on step 3, when it retries, it will simply jump right back to trying to execute the method defined for the 3rd step, _**and won't even execute the first two step methods**_. This means your step methods only need to be idempotent on failure, not on success, since they will never be run again if they succeed.
164
116
 
165
- In some cases, you may need to _dynamically_ determine the collection of jobs that the step should wait for; in these cases, you can pass the name of a method to the `awaits` option:
117
+ By default, each step is executed and upon completion, the `AcidicJob::Execution` record is updated to reflect the completion of that step. This default makes sense for _foreign state mutations_, but for _local state mutations_, i.e. writes to your application's primary database, it makes sense to wrap the both the step execution and the record update in a single transaction. This is done by passing the `transactional` option to the `step` method:
166
118
 
167
119
  ```ruby
168
- class RideCreateJob < ActiveJob::Base
169
- include AcidicJob
170
- set_callback :finish, :after, :delete_run_record
171
-
172
- def perform(user_id, ride_params)
173
- @user = User.find(user_id)
174
- @params = ride_params
175
-
176
- with_acidity providing: { ride: nil } do
177
- step :create_ride_and_audit_record, awaits: :dynamic_awaits
178
- step :create_stripe_charge
179
- step :send_receipt
180
- end
181
- end
182
-
183
- def dynamic_awaits
184
- if @params["key"].present?
185
- [SomeJob.with('argument_1', keyword: 'value')]
186
- else
187
- [AnotherJob.with(1, 2, 3, some: 'thing')]
188
- end
189
- end
120
+ execute_workflow(unique_by: [@user, @params]) do |workflow|
121
+ workflow.step :create_ride_and_audit_record, transactional: true
122
+ workflow.step :create_stripe_charge
123
+ workflow.step :send_receipt
190
124
  end
191
125
  ```
192
126
 
193
127
 
194
- ### Iterable Steps
195
-
196
- Sometimes our workflows have steps that need to iterate over a collection and perform an action for each item in the collection before moving on to the next step in the workflow. In these cases, we can use the `for_each` option when defining our step to specific the collection, and `acidic_job` will pass each item into your step method for processing, keeping the same transactional guarantees as for any step. This means that if your step encounters an error in processing any item in the collection, when your job is retried, the job will jump right back to that step and right back to that item in the collection to try again.
197
-
198
- ```ruby
199
- class ExampleJob < ActiveJob::Base
200
- include AcidicJob
201
-
202
- def perform(record:)
203
- with_acidity providing: { collection: [1, 2, 3, 4, 5] } do
204
- step :process_item, for_each: :collection
205
- step :next_step
206
- end
207
- end
208
-
209
- def process_item(item)
210
- # do whatever work needs to be done with this individual item
211
- end
212
- end
213
- ```
214
-
215
- **Note:** This feature relies on the "Persisted Attributes" feature detailed below. This means that you can only iterate over collections that ActiveRecord can serialize.
216
-
217
-
218
128
  ### Persisted Attributes
219
129
 
220
- Any objects passed to the `providing` option on the `with_acidity` method are made available across retries. This means that you can set an attribute in step 1, access it in step 2, have step 2 fail, have the job retry, jump directly back to step 2 on retry, and have that object still accessible. This is done by serializing all objects to a field on the `AcidicJob::Run` and manually providing getters and setters that sync with the database record.
130
+ In addition to the workflow steps, `AcidicJob` also provides you with an isolated context where you can persist data that is needed across steps and across retries. This means that you can set an attribute in step 1, access it in step 2, have step 2 fail, have the job retry, jump directly back to step 2 on retry, and have that object still accessible. This is available via the `ctx` object, which is an instance of `AcidicJob::Context`, in all of your step methods:
221
131
 
222
132
  ```ruby
223
- class RideCreateJob < ActiveJob::Base
224
- include AcidicJob
225
-
133
+ class RideCreateJob < AcidicJob::Base
226
134
  def perform(user_id, ride_params)
227
135
  @user = User.find(user_id)
228
136
  @params = ride_params
229
137
 
230
- with_acidity providing: { ride: nil } do
231
- step :create_ride_and_audit_record
232
- step :create_stripe_charge
233
- step :send_receipt
138
+ execute_workflow(unique_by: [@user, @params]) do |workflow|
139
+ workflow.step :create_ride_and_audit_record, transactional: true
140
+ workflow.step :create_stripe_charge
141
+ workflow.step :send_receipt
234
142
  end
235
143
  end
236
144
 
237
145
  def create_ride_and_audit_record
238
- self.ride = Ride.create!
146
+ ctx[:ride] = @user.rides.create(@params)
239
147
  end
240
148
 
241
149
  def create_stripe_charge
242
- Stripe::Charge.create(amount: 20_00, customer: @ride.user)
150
+ Stripe::Charge.create(amount: 20_00, customer: ctx[:ride].user)
243
151
  end
244
152
 
245
153
  # ...
246
154
  end
247
155
  ```
248
156
 
249
- **Note:** This does mean that you are restricted to objects that can be serialized by **`ActiveJob`** (for more info, see [here](https://edgeguides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments)). This means you can persist ActiveRecord models, and any simple Ruby data types, but you can't persist things like Procs or custom class instances, for example.
157
+ As you see, you access the `ctx` object as if it were a hash, though it is a custom `AcidicJob::Context` object that persists the data to `AcidicJob::Value` records associated with the workflow's `AcidicJob::Execution` record.
250
158
 
251
- **Note:** You will note the use of `self.ride = ...` in the code sample above. In order to call the attribute setter method that will sync with the database record, you _must_ use this style. `@ride = ...` and/or `ride = ...` will both fail to sync the value with the database record.
159
+ > [!NOTE]
160
+ > This does mean that you are restricted to objects that can be serialized by **_`ActiveJob`_** (for more info, see [the Rails Guide on `ActiveJob`](https://edgeguides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments)). This means you can persist Active Record models, and any simple Ruby data types, but you can't persist things like Procs or custom class instances, for example. `AcidicJob` does, though, extend the standard set of supported types to include Active Job instances themselves, unpersisted Active Record instances, and Ruby exceptions.
252
161
 
253
- The default pattern you should follow when defining your `perform` method is to make any values that your `step` methods need access to, but are present at the start of the `perform` method simply instance variables. You only need to `provide` attributes that will be set _during a step_. This means, the initial value will almost always be `nil`.
162
+ As the code sample also suggests, you should always use standard instance variables defined in your `perform` method when you have any values that your `step` methods need access to, but are present at the start of the `perform` method. You only need to persist attributes that will be set _during a step_ via `ctx`.
254
163
 
255
164
 
256
- ### Transactionally Staged Jobs
165
+ ### Custom Workflow Uniqueness
257
166
 
258
- A standard problem when inside of database transactions is enqueuing other jobs. On the one hand, you could enqueue a job inside of a transaction that then rollbacks, which would leave that job to fail and retry and fail. On the other hand, you could enqueue a job that is picked up before the transaction commits, which would mean the records are not yet available to this job.
167
+ Resilient workflows must, necessarily, be idempotent.[^2] Idempotency is a fancy word that simply means your jobs need to be able to be run multiple times while any side effects only happen once. In order for your workflow executions to be idempotent, `AcidicJob` needs to know what constitutes a unique execution of your job. You can define what makes your job unique by passing the `unique_by` argument when executing the workflow:
259
168
 
260
- In order to mitigate against such issues without forcing you to use a database-backed job queue, `acidic_job` provides `perform_acidicly` and `deliver_acidicly` methods to "transactionally stage" enqueuing other jobs from within a step (whether another ActiveJob or a Sidekiq::Worker or an ActionMailer delivery). These methods will create a new `AcidicJob::Run` record, but inside of the database transaction of the `step`. Upon commit of that transaction, a model callback pushes the job to your actual job queue. Once the job has been successfully performed, the `AcidicJob::Run` record is deleted so that this table doesn't grow unbounded and unnecessarily.
169
+ [^2]: This is echoed both by [Mike Perham](https://www.mikeperham.com), the creator of Sidekiq, in the Sidekiq [docs on best practices](https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional) by the GitLab team in their [Sidekiq Style Guide](https://docs.gitlab.com/ee/development/sidekiq/idempotent_jobs.html).
261
170
 
262
171
  ```ruby
263
- class RideCreateJob < ActiveJob::Base
264
- include AcidicJob
172
+ class Job < ActiveJob::Base
173
+ include AcidicJob::Workflow
265
174
 
266
- def perform(user_id, ride_params)
267
- @user = User.find(user_id)
268
- @params = ride_params
269
-
270
- with_acidity providing: { ride: nil } do
271
- step :create_ride_and_audit_record
272
- step :create_stripe_charge
273
- step :send_receipt
175
+ def perform(record:)
176
+ execute_workflow(unique_by: [record.id, record.status]) do |w|
177
+ w.step :step_1
178
+ w.step :step_2
179
+ w.step :step_3
274
180
  end
275
181
  end
276
-
277
- # ...
278
-
279
- def send_receipt
280
- RideMailer.with(user: @user, ride: @ride).confirm_charge.delivery_acidicly
281
- end
282
- end
283
182
  ```
284
183
 
184
+ > [!TIP]
185
+ > You should think carefully about what constitutes a unique execution of a workflow. Imagine you had a workflow job for balance transers. Jill transfers $10 to John. Your system **must** be able to differentiate between retries of this transfer and new independent transfers. If you were only to use the `sender`, `recipient`, and `amount` as your `unique_by` values, then if Jill tries to transfer another $10 to John at some point in the future, that work will be considered a retry of the first transfer and not a new transfer.
285
186
 
286
- ### Custom Idempotency Keys
287
-
288
- By default, `AcidicJob` uses the job identifier provided by the queueing system (ActiveJob or Sidekiq) as the idempotency key for the job run. The idempotency key is what is used to guarantee that no two runs of the same job occur. However, sometimes we need particular jobs to be idempotent based on some other criteria. In these cases, `AcidicJob` provides a collection of tools to allow you to ensure the idempotency of your jobs.
289
187
 
290
- Firstly, you can configure your job class to explicitly use either the job identifier or the job arguments as the foundation for the idempotency key. A job class that calls the `acidic_by_job_id` class method (which is the default behavior) will simply make the job run's idempotency key the job's identifier:
188
+ ### Orchestrating steps
291
189
 
292
- ```ruby
293
- class ExampleJob < ActiveJob::Base
294
- include AcidicJob
295
- acidic_by_job_id
296
-
297
- def perform
298
- end
299
- end
300
- ```
190
+ In addition to the workflow definition setup, `AcidicJob` also provides a couple of methods to precisely control the workflow step execution. From within any step method, you can call either `repeat_step!` or `halt_step!`.
301
191
 
302
- Conversely, a job class can use the `acidic_by_job_args` method to configure that job class to use the arguments passed to the job as the foundation for the job run's idempotency key:
192
+ `repeat_step!` will cause the current step to be re-executed on the next iteration of the workflow. This is useful when you need to traverse a collection of items and perform the same operation on each item. For example, if you need to send an email to each user in a collection, you could do something like this:
303
193
 
304
194
  ```ruby
305
- class ExampleJob < ActiveJob::Base
306
- include AcidicJob
307
- acidic_by_job_args
195
+ class Job < ActiveJob::Base
196
+ include AcidicJob::Workflow
308
197
 
309
- def perform(arg_1, arg_2)
310
- # the idempotency key will be based on whatever the values of `arg_1` and `arg_2` are
198
+ def perform(users)
199
+ @users = users
200
+ execute_workflow(unique_by: @users) do |w|
201
+ w.step :notify_users
202
+ end
311
203
  end
312
- end
313
- ```
314
204
 
315
- These options cover the two common situations, but sometimes our systems need finer-grained control. For example, our job might take some record as the job argument, but we need to use a combination of the record identifier and record status as the foundation for the idempotency key. In these cases you can pass a `Proc` to an `acidic_by` class method:
205
+ def notify_users
206
+ cursor = ctx[:cursor] || 0
207
+ user = @users[cursor]
208
+ return if user.nil?
316
209
 
317
- ```ruby
318
- class ExampleJob < ActiveJob::Base
319
- include AcidicJob
320
- acidic_by ->(record:) { [record.id, record.status] }
210
+ UserMailer.with(user: user).welcome_email.deliver_later
321
211
 
322
- def perform(record:)
323
- # the idempotency key will be based on whatever the values of `record.id` and `record.status` are
212
+ ctx[:cursor] = cursor + 1
213
+ repeat_step!
324
214
  end
325
215
  end
326
216
  ```
327
217
 
328
- > **Note:** The signature of the `acidic_by` proc _needs to match the signature_ of the job's `perform` method.
329
-
330
-
331
- ### Sidekiq Callbacks
332
-
333
- In order to ensure that `AcidicJob::Staged` records are only destroyed once the related job has been successfully performed, whether it is an ActiveJob or a Sidekiq Worker, `acidic_job` also extends Sidekiq to support the [ActiveJob callback interface](https://edgeguides.rubyonrails.org/active_job_basics.html#callbacks).
334
-
335
- This allows `acidic_job` to use an `after_perform` callback to delete the `AcidicJob::Staged` record, whether you are using the gem with ActiveJob or pure Sidekiq Workers. Of course, this means that you can add your own callbacks to any jobs or workers that include the `AcidicJob` module as well.
336
-
218
+ This example demonstrates how you can leverage the basic building blocks provided by `AcidicJob` to orchestrate complex workflows. In this case, the `notify_users` step sends an email to each user in the collection, one at a time, and resiliently handles errors by storing a cursor in the `ctx` object to keep track of the current user being processed. If any error occurs while traversing the `@users` collection, the job will be retried, and the `notify_users` step will be re-executed from the last successful cursor position.
337
219
 
338
- ### Run Finished Callbacks
339
-
340
- When working with workflow jobs that make use of the `awaits` feature for a step, it is important to remember that the `after_perform` callback will be called _as soon as the first `awaits` step has enqueued job_, and **not** when the entire job run has finished. `acidic_job` allows the `perform` method to finish so that the queue for the workflow job is cleared to pick up new work while the `awaits` jobs are running. `acidic_job` will automatically re-enqueue the workflow job and progress to the next step when all of the `awaits` jobs have successfully finished. However, this means that `after_perform` **is not necessarily** the same as `after_finish`. In order to provide the opportunity for you to execute callback logic _if and only if_ a job run has finished, we provide callback hooks for the `finish` event.
341
-
342
- For example, you could use this hook to immediately clean up the `AcidicJob::Run` database record whenever the workflow job finishes successfully like so:
220
+ The `halt_step!` method, on the other hand, stops not just the execution of the current step but the job as a whole. This is useful when you either need to conditionally stop the workflow based on some criteria or need to delay the job for some amount of time before being restarted. For example, if you need to send a follow-up email to a user 14 days after they sign up, you could do something like this:
343
221
 
344
222
  ```ruby
345
- class RideCreateJob < ActiveJob::Base
346
- include AcidicJob
347
- set_callback :finish, :after, :delete_run_record
348
-
349
- def perform(user_id, ride_params)
350
- @user = User.find(user_id)
351
- @params = ride_params
352
-
353
- with_acidity providing: { ride: nil } do
354
- step :create_ride_and_audit_record, awaits: [SomeJob.with('argument_1', keyword: 'value')]
355
- step :create_stripe_charge, args: [1, 2, 3], kwargs: { some: 'thing' }
356
- step :send_receipt
223
+ class Job < ActiveJob::Base
224
+ include AcidicJob::Workflow
225
+
226
+ def perform(user)
227
+ @user = user
228
+ execute_workflow(unique_by: @user) do |w|
229
+ w.step :delay
230
+ w.step :send_welcome_email
357
231
  end
358
232
  end
359
-
360
- def delete_run_record
361
- return unless acidic_job_run.succeeded?
362
233
 
363
- acidic_job_run.destroy!
234
+ def delay
235
+ enqueue(wait: 14.days)
236
+ ctx[:halt] = true
237
+ end
238
+
239
+ def send_welcome_email
240
+ if ctx[:halt]
241
+ ctx[:halt] = false
242
+ halt_step!
243
+ end
244
+ UserMailer.with(user: @user).welcome_email.deliver_later
364
245
  end
365
246
  end
366
247
  ```
367
248
 
249
+ In this example, the `delay` step creates a new instance of the job and enqueues it to run 14 days in the future. It then sets a flag in the `ctx` object to halt the job. We want to halt the job in the following step and only halt it once. This ensures that when the job is re-enqueued and performed, it jumps to the `send_welcome_email` step and that step send the email only on this second run of the job. By checking for this flag and, if it is set, clears the flag and halting the job, the `send_welcome_email` step can free the worker queue from doing work, let the system waits 2 weeks, and then pick right back up where it paused originally.
250
+
251
+
252
+ ### Overview
253
+
254
+ `AcidicJob` is a library that provides a small yet powerful set of tools to build cohesive and resilient workflows in your Active Jobs. All of the tools are made available by `include`ing the `AcidicJob::Workflow` module. The primary and most important tool is the `execute_workflow` method, which you call within your `perform` method. Then, if you need to store any contextual data, you use the `ctx` objects setters and getters. Finally, within any step methods, you can call `repeat_step!` or `halt_step!` to control the execution of the workflow. If you need, you can also access the `execution` Active Record object to get information about the current execution of the workflow. With these lightweight tools, you can build complex workflows that are resilient to failures and can handle a wide range of use cases.
255
+
368
256
 
369
257
  ## Testing
370
258
 
@@ -374,13 +262,13 @@ When testing acidic jobs, you are likely to run into `ActiveRecord::TransactionI
374
262
  ActiveRecord::TransactionIsolationError: cannot set transaction isolation in a nested transaction
375
263
  ```
376
264
 
377
- This error is thrown because by default RSpec and most MiniTest test suites use database transactions to keep the test database clean between tests. The database transaction that is wrapping all of the code executed in your test is run at the standard isolation level, but acidic jobs then try to create another transaction run at a more conservative isolation level. You cannot have a nested transaction that runs at a different isolation level, thus, this error.
265
+ This error is thrown because by default RSpec and most MiniTest test suites use database transactions to keep the test database clean between tests. The database transaction that is wrapping all of the code executed in your test is run at the standard isolation level, but `AcidicJob` then tries to create another transaction at a more conservative isolation level. You cannot have a nested transaction that runs at a different isolation level, thus, this error.
378
266
 
379
267
  In order to avoid this error, you need to ensure firstly that your tests that run your acidic jobs are not using a database transaction and secondly that they use some different strategy to keep your test database clean. The [DatabaseCleaner](https://github.com/DatabaseCleaner/database_cleaner) gem is a commonly used tool to manage different strategies for keeping your test database clean. As for which strategy to use, `truncation` and `deletion` are both safe, but their speed varies based on our app's table structure (see https://github.com/DatabaseCleaner/database_cleaner#what-strategy-is-fastest). Either is fine; use whichever is faster for your app.
380
268
 
381
- In order to make this test setup simpler, `AcidicJob` provides a `TestCase` class that your MiniTest jobs tests can inherit from. It is simple; it inherits from `ActiveJob::TestCase`, sets `use_transactional_tests` to `false`, and ensures `DatabaseCleaner` is run for each of your tests. Moreover, it ensures that the system's original DatabaseCleaner configuration is maintained, options included, except that any `transaction` strategies for any ORMs are replaced with a `deletion` strategy. It does so by storing whatever the system DatabaseCleaner configuration is at the start of `before_setup` phase in an instance variable and then restores that configuration at the end of `after_teardown` phase. In between, it runs the configuration thru a pipeline that selectively replaces any `transaction` strategies with a corresponding `deletion` strategy, leaving any other configured strategies untouched.
269
+ In order to make this test setup simpler, `AcidicJob` provides a `Testing` module that your job tests can include. It is simple; it sets `use_transactional_tests` to `false` (if the test is an `ActiveJob::TestCase`), and ensures a transaction-safe `DatabaseCleaner` strategy is run for each of your tests. Moreover, it ensures that the system's original DatabaseCleaner configuration is maintained, options included, except that any `transaction` strategies for any ORMs are replaced with a `deletion` strategy. It does so by storing whatever the system DatabaseCleaner configuration is at the start of `before_setup` phase in an instance variable and then restores that configuration at the end of `after_teardown` phase. In between, it runs the configuration thru a pipeline that selectively replaces any `transaction` strategies with a corresponding `deletion` strategy, leaving any other configured strategies untouched.
382
270
 
383
- For those of you using RSpec, you can require the `acidic_job/rspec_configuration` file, which will configure RSpec in the exact same way I have used in my RSpec projects to allow me to test acidic jobs with either the `deletion` strategy but still have all of my other tests use the fast `transaction` strategy:
271
+ For those of you using RSpec, use this as a baseline to configure RSpec in the exact same way I have used in my RSpec projects to allow me to test `AcidicJob` with the `deletion` strategy but still have all of my other tests use the fast `transaction` strategy:
384
272
 
385
273
  ```ruby
386
274
  require "database_cleaner/active_record"
@@ -413,12 +301,41 @@ RSpec.configure do |config|
413
301
  end
414
302
  ```
415
303
 
304
+
416
305
  ## Development
417
306
 
418
307
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
419
308
 
420
309
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
421
310
 
311
+ You can run a specific Rails version using one of the Gemfiles defined in the `/gemfiles` directory via the `BUNDLE_GEMFILE` ENV variable, e.g.:
312
+
313
+ ```sh
314
+ BUNDLE_GEMFILE=gemfiles/rails_7.0.gemfile bundle exec rake test
315
+ ```
316
+
317
+ You can likewise test only one particular test file using the `TEST` ENV variable, e.g.:
318
+
319
+ ```sh
320
+ TEST=test/acidic_job/basics_test.rb
321
+ ```
322
+
323
+ Finally, if you need to only run one particular test case itself, use the `TESTOPTS` ENV variable with the `--name` option, e.g.:
324
+
325
+ ```sh
326
+ TESTOPTS="--name=test_workflow_with_each_step_succeeding"
327
+ ```
328
+
329
+ You may also need to run the test suite with a particular Ruby version. If you are using the ASDF version manager, you can set the Ruby version with the `ASDF_RUBY_VERSION` ENV variable, e.g.:
330
+
331
+ ```sh
332
+ ASDF_RUBY_VERSION=2.7.7 bundle exec rake test
333
+ ```
334
+
335
+ If you are using `rbenv` to manage your Ruby versions, you can use the `RBENV_VERSION` ENV variable instead.
336
+
337
+ These options can of course be combined to help narrow down your debugging when you find a failing test in CI.
338
+
422
339
  ## Contributing
423
340
 
424
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/acidic_job.
341
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fractaledmind/acidic_job.