acidic_job 1.0.0.beta.10 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +12 -36
  3. data/.gitignore +0 -5
  4. data/.ruby_version +1 -0
  5. data/Gemfile +31 -0
  6. data/Gemfile.lock +130 -136
  7. data/README.md +58 -278
  8. data/acidic_job.gemspec +2 -15
  9. data/bin/console +2 -4
  10. data/lib/acidic_job/awaiting.rb +68 -0
  11. data/lib/acidic_job/errors.rb +19 -11
  12. data/lib/acidic_job/extensions/action_mailer.rb +11 -3
  13. data/lib/acidic_job/extensions/active_job.rb +39 -0
  14. data/lib/acidic_job/extensions/noticed.rb +11 -5
  15. data/lib/acidic_job/extensions/sidekiq.rb +101 -0
  16. data/lib/acidic_job/finished_point.rb +5 -3
  17. data/lib/acidic_job/idempotency_key.rb +15 -18
  18. data/lib/acidic_job/perform_wrapper.rb +36 -9
  19. data/lib/acidic_job/recovery_point.rb +3 -2
  20. data/lib/acidic_job/run.rb +42 -268
  21. data/lib/acidic_job/staging.rb +30 -0
  22. data/lib/acidic_job/step.rb +83 -0
  23. data/lib/acidic_job/version.rb +1 -1
  24. data/lib/acidic_job.rb +244 -20
  25. data/lib/generators/acidic_job_generator.rb +35 -0
  26. data/lib/generators/templates/create_acidic_job_runs_migration.rb.erb +19 -0
  27. metadata +15 -209
  28. data/.github/FUNDING.yml +0 -13
  29. data/.tool-versions +0 -1
  30. data/UPGRADE_GUIDE.md +0 -81
  31. data/combustion/log/test.log +0 -0
  32. data/gemfiles/rails_6.1_sidekiq_6.4.gemfile +0 -10
  33. data/gemfiles/rails_6.1_sidekiq_6.5.gemfile +0 -10
  34. data/gemfiles/rails_7.0_sidekiq_6.4.gemfile +0 -10
  35. data/gemfiles/rails_7.0_sidekiq_6.5.gemfile +0 -10
  36. data/gemfiles/rails_7.1_sidekiq_6.4.gemfile +0 -10
  37. data/gemfiles/rails_7.1_sidekiq_6.5.gemfile +0 -10
  38. data/lib/acidic_job/active_kiq.rb +0 -114
  39. data/lib/acidic_job/arguments.rb +0 -22
  40. data/lib/acidic_job/base.rb +0 -11
  41. data/lib/acidic_job/logger.rb +0 -31
  42. data/lib/acidic_job/mixin.rb +0 -250
  43. data/lib/acidic_job/processor.rb +0 -95
  44. data/lib/acidic_job/rails.rb +0 -40
  45. data/lib/acidic_job/serializer.rb +0 -24
  46. data/lib/acidic_job/serializers/exception_serializer.rb +0 -41
  47. data/lib/acidic_job/serializers/finished_point_serializer.rb +0 -24
  48. data/lib/acidic_job/serializers/job_serializer.rb +0 -27
  49. data/lib/acidic_job/serializers/range_serializer.rb +0 -28
  50. data/lib/acidic_job/serializers/recovery_point_serializer.rb +0 -25
  51. data/lib/acidic_job/serializers/worker_serializer.rb +0 -27
  52. data/lib/acidic_job/test_case.rb +0 -9
  53. data/lib/acidic_job/testing.rb +0 -73
  54. data/lib/acidic_job/workflow.rb +0 -70
  55. data/lib/acidic_job/workflow_builder.rb +0 -35
  56. data/lib/acidic_job/workflow_step.rb +0 -103
  57. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  58. data/lib/generators/acidic_job/install_generator.rb +0 -27
  59. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
  60. data/lib/generators/acidic_job/templates/drop_acidic_job_keys_migration.rb.erb +0 -27
data/README.md CHANGED
@@ -1,11 +1,8 @@
1
1
  # AcidicJob
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
+ ### Idempotent operations for Rails apps (for ActiveJob or Sidekiq)
5
4
 
6
- ## Idempotent operations for Rails apps (for ActiveJob or Sidekiq)
7
-
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 operations 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.
5
+ 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
6
 
10
7
  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):
11
8
 
@@ -15,33 +12,14 @@ However, in order to ensure that our operational jobs are _robust_, we need to e
15
12
 
16
13
  This is, of course, far easier said than done. Thus, `AcidicJob`.
17
14
 
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](https://twitter.com/brandur), which together lay out core techniques and principles required to make an HTTP API properly ACIDic:
19
-
20
- 1. [Building Robust Systems with ACID and Constraints](https://brandur.org/acid)
21
- 2. [Using Atomic Transactions to Power an Idempotent API](https://brandur.org/http-transactions)
22
- 3. [Transactionally Staged Job Drains in Postgres](https://brandur.org/job-drain)
23
- 4. [Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys)
24
-
25
- Seriously, go and read these articles. `AcidicJob` brings these techniques and principles into the world of a standard Rails application, treating your background jobs like an internal API of sorts. It provides a suite of functionality that empowers you to create complex, robust, and _acidic_ jobs.
26
-
27
- ## Key Features
28
-
29
- * **Transactional Steps**
30
- 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".
31
- * **Steps that Await Jobs**
32
- 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
33
- * **Iterable Steps**
34
- define steps that iterate over some collection fully until moving on to the next step
35
- * **Persisted Attributes**
36
- when retrying jobs at later steps, we need to ensure that data created in previous steps is still available to later steps on retry.
37
- * **Transactionally Staged Jobs**
38
- enqueue additional jobs within the acidic transaction safely
39
- * **Custom Idempotency Keys**
40
- use something other than the job ID for the idempotency key of the job run
41
- * **Sidekiq Callbacks**
42
- bring ActiveJob-like callbacks into your pure Sidekiq Workers
43
- * **Run Finished Callbacks**
44
- set callbacks for when a job run finishes fully
15
+ `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:
16
+
17
+ 1. https://brandur.org/acid
18
+ 2. https://brandur.org/http-transactions
19
+ 3. https://brandur.org/job-drain
20
+ 4. https://brandur.org/idempotency-keys
21
+
22
+ `AcidicJob` brings these techniques and principles into the world of a standard Rails application.
45
23
 
46
24
  ## Installation
47
25
 
@@ -62,50 +40,51 @@ Or simply execute to install the gem yourself:
62
40
  Then, use the following command to copy over the `AcidicJob::Run` migration file.
63
41
 
64
42
  ```
65
- rails generate acidic_job:install
43
+ rails generate acidic_job
66
44
  ```
67
45
 
68
46
  ## Usage
69
47
 
70
- `AcidicJob` brings the most seamless experience when you inject it into every job in your application. This can be done most easily by simply having your `ApplicationJob` inherit from `AcidicJob::Base` (if using `ActiveJob`; inherit from `AcidicJob::ActiveKiq` if using pure Sidekiq workers):
48
+ `AcidicJob` is a concern that you `include` into your base `ApplicationJob`.
71
49
 
72
50
  ```ruby
73
- class ApplicationJob < AcidicJob::Base
51
+ class ApplicationJob < ActiveJob::Base
52
+ include AcidicJob
74
53
  end
75
54
  ```
76
55
 
77
- This is useful because the module needs to be mixed into any and all jobs that you want to either [1] make acidic or [2] enqueue acidicly.
56
+ 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.
78
57
 
79
- If you only want to inject `AcidicJob` into a single job, you can include our concern `AcidicJob::Mixin` instead:
58
+ It provides a suite of functionality that empowers you to create complex, robust, and _acidic_ jobs.
80
59
 
81
- ```ruby
82
- class SomeJob < ApplicationJob
83
- include AcidicJob::Mixin
84
- end
85
- ```
60
+ ### TL;DR
86
61
 
87
- ## Key Features (in depth)
62
+ #### Key Features
88
63
 
64
+ * Transactional Steps — 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".
65
+ * Persisted Attributes — when retrying jobs at later steps, we need to ensure that data created in previous steps is still available to later steps on retry.
66
+ * Transactionally Staged Jobs — enqueue additional jobs within the acidic transaction safely
67
+ * Sidekiq Callbacks — bring ActiveJob-like callbacks into your pure Sidekiq Workers
68
+ * Sidekiq Batches — leverage the power of Sidekiq Pro's `batch` functionality without the hassle
89
69
 
90
70
  ### Transactional Steps
91
71
 
92
- The first and foundational feature `acidic_job` provides is the `with_acidic_workflow` method, which takes a block of transactional step methods (defined via the `step`) method:
72
+ 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:
93
73
 
94
74
  ```ruby
95
- class RideCreateJob < AcidicJob::Base
75
+ class RideCreateJob < ActiveJob::Base
76
+ include AcidicJob
77
+
96
78
  def perform(user_id, ride_params)
97
- @user = User.find(user_id)
98
- @params = ride_params
79
+ user = User.find(user_id)
99
80
 
100
- with_acidic_workflow persisting: { ride: nil } do |workflow|
101
- workflow.step :create_ride_and_audit_record
102
- workflow.step :create_stripe_charge
103
- workflow.step :send_receipt
81
+ with_acidity given: { user: user, params: ride_params, ride: nil } do
82
+ step :create_ride_and_audit_record
83
+ step :create_stripe_charge
84
+ step :send_receipt
104
85
  end
105
86
  end
106
87
 
107
- private
108
-
109
88
  def create_ride_and_audit_record
110
89
  # ...
111
90
  end
@@ -120,114 +99,20 @@ class RideCreateJob < AcidicJob::Base
120
99
  end
121
100
  ```
122
101
 
123
- `with_acidic_workflow` takes only the `persisting:` named parameter (optionally) and a block (required) where you define the steps of this operation. `step` simply takes the name of a method available in the job. That's all!
124
-
125
- 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 us 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.
126
-
127
-
128
- ### Steps that Await Jobs
129
-
130
- 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 (and only once) all 3 jobs succeed, `AcidicJob` 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.
131
-
132
- ```ruby
133
- class RideCreateJob < AcidicJob::Base
134
- def perform(user_id, ride_params)
135
- @user = User.find(user_id)
136
- @params = ride_params
137
-
138
- with_acidic_workflow persisting: { ride: nil } do |workflow|
139
- workflow.step :create_ride_and_audit_record, awaits: [SomeJob, AnotherJob]
140
- workflow.step :create_stripe_charge
141
- workflow.step :send_receipt
142
- end
143
- end
144
- end
145
- ```
146
-
147
- 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 `AcidicJob` will add to your jobs:
148
-
149
- ```ruby
150
- class RideCreateJob < AcidicJob::Base
151
- def perform(user_id, ride_params)
152
- @user = User.find(user_id)
153
- @params = ride_params
154
-
155
- with_acidic_workflow persisting: { ride: nil } do |workflow|
156
- step :create_ride_and_audit_record, awaits: awaits: [SomeJob.with('argument_1', keyword: 'value'), AnotherJob.with(1, 2, 3, some: 'thing')]
157
- step :create_stripe_charge
158
- step :send_receipt
159
- end
160
- end
161
- end
162
- ```
163
-
164
- 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.
165
-
166
- 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:
167
-
168
- ```ruby
169
- class RideCreateJob < AcidicJob::Base
170
- def perform(user_id, ride_params)
171
- @user = User.find(user_id)
172
- @params = ride_params
173
-
174
- with_acidic_workflow persisting: { ride: nil } do |workflow|
175
- step :create_ride_and_audit_record, awaits: :dynamic_awaits
176
- step :create_stripe_charge
177
- step :send_receipt
178
- end
179
- end
180
-
181
- private
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
190
- end
191
- ```
192
-
193
-
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 bind that method to a specific the collection, and `AcidicJob` 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 < AcidicJob::Base
200
- def perform(record:)
201
- with_acidic_workflow persisting: { collection: [1, 2, 3, 4, 5] } do |workflow|
202
- workflow.step :process_item, for_each: :collection
203
- workflow.step :next_step
204
- end
205
- end
206
-
207
- private
208
-
209
- def process_item(item)
210
- # do whatever work needs to be done with an individual item from `collection`
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 ActiveJob can serialize. See [the Rails Guide on `ActiveJob`](https://edgeguides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments) for more info.
102
+ `with_acidity` takes only the `given:` 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!
216
103
 
104
+ 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.
217
105
 
218
106
  ### Persisted Attributes
219
107
 
220
- The `persisting` option on the `with_acidic_workflow` method allows you to create a cross-step, cross-retry context. 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 persisting getters and setters that sync with the database record.
221
-
222
- 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 mark attributes that will be set _during a step_ via `persisting`. This means, the initial value will almost always be `nil`. If you need a default initial value, however, you can always provide that value to `persisting`.
108
+ Any objects passed to the `given` option on the `with_acidity` method are not just made available to each of your step methods, they 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.
223
109
 
224
110
  ```ruby
225
- class RideCreateJob < AcidicJob::Base
226
- def perform(user_id, ride_params)
227
- @user = User.find(user_id)
228
- @params = ride_params
111
+ class RideCreateJob < ActiveJob::Base
112
+ include AcidicJob
229
113
 
230
- with_acidic_workflow persisting: { ride: nil } do |workflow|
114
+ def perform(ride_params)
115
+ with_acidity given: { ride: nil } do
231
116
  step :create_ride_and_audit_record
232
117
  step :create_stripe_charge
233
118
  step :send_receipt
@@ -239,31 +124,31 @@ class RideCreateJob < AcidicJob::Base
239
124
  end
240
125
 
241
126
  def create_stripe_charge
242
- Stripe::Charge.create(amount: 20_00, customer: @ride.user)
127
+ Stripe::Charge.create(amount: 20_00, customer: self.ride.user)
243
128
  end
244
129
 
245
130
  # ...
246
131
  end
247
132
  ```
248
133
 
249
- **Note:** 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 ActiveRecord models, and any simple Ruby data types, but you can't persist things like Procs or custom class instances, for example.
134
+ **Note:** This does mean that you are restricted to objects that can be serialized by ActiveRecord, thus no Procs, for example.
250
135
 
251
136
  **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.
252
137
 
253
-
254
138
  ### Transactionally Staged Jobs
255
139
 
256
140
  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.
257
141
 
258
- In order to mitigate against such issues without forcing you to use a database-backed job queue, `AcidicJob` 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.
142
+ 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.
259
143
 
260
144
  ```ruby
261
- class RideCreateJob < AcidicJob::Base
145
+ class RideCreateJob < ActiveJob::Base
146
+ include AcidicJob
147
+
262
148
  def perform(user_id, ride_params)
263
- @user = User.find(user_id)
264
- @params = ride_params
149
+ user = User.find(user_id)
265
150
 
266
- with_acidic_workflow persisting: { ride: nil } do |workflow|
151
+ with_acidity given: { user: user, params: ride_params, ride: nil } do
267
152
  step :create_ride_and_audit_record
268
153
  step :create_stripe_charge
269
154
  step :send_receipt
@@ -278,137 +163,32 @@ class RideCreateJob < AcidicJob::Base
278
163
  end
279
164
  ```
280
165
 
281
-
282
- ### Custom Idempotency Keys
283
-
284
- 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.
285
-
286
- 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:
287
-
288
- ```ruby
289
- class ExampleJob < AcidicJob::Base
290
- acidic_by_job_id
291
-
292
- def perform
293
- end
294
- end
295
- ```
296
-
297
- 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:
298
-
299
- ```ruby
300
- class ExampleJob < AcidicJob::Base
301
- acidic_by_job_args
302
-
303
- def perform(arg_1, arg_2)
304
- # the idempotency key will be based on whatever the values of `arg_1` and `arg_2` are
305
- end
306
- end
307
- ```
308
-
309
- 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:
310
-
311
- ```ruby
312
- class ExampleJob < AcidicJob::Base
313
- acidic_by -> { [@record.id, @record.status] }
314
-
315
- def perform(record:)
316
- @record = record
317
-
318
- # the idempotency key will be based on whatever the values of `@record.id` and `@record.status` are
319
- with_acidic_workflow do |workflow|
320
- workflow.step :do_something
321
- end
322
- end
323
- end
324
- ```
325
-
326
- > **Note:** The `acidic_by` proc _will be executed in the context of the job instance_ at the moment the `with_acidic_workflow` method is called. This means it will have access to any instance variables defined in your `perform` method up to that point.
327
-
328
-
329
166
  ### Sidekiq Callbacks
330
167
 
331
- In order to ensure that staged `AcidicJob::Run` records are only destroyed once the related job has been successfully performed, whether it is an ActiveJob or a Sidekiq Worker, `AcidicJob` also extends Sidekiq to support the [ActiveJob callback interface](https://edgeguides.rubyonrails.org/active_job_basics.html#callbacks).
168
+ 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).
332
169
 
333
- This allows us to use an `after_perform` callback to delete the `AcidicJob::Run` 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.
170
+ 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.
334
171
 
172
+ ### Sidekiq Batches
335
173
 
336
- ### Run Finished Callbacks
174
+ One final feature for those of you using Sidekiq Pro: an integrated DSL for Sidekiq Batches. By simply adding the `awaits` option to your step declarations, you can attach any number of additional, asynchronous workers 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 workers enqueued on 3 different queues, each running in parallel. Once all 3 workers succeed, `acidic_job` will move on to step 2. That's right, by leveraging the power of Sidekiq Batches, 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, and in my mind is the number one selling point for Sidekiq Pro.
337
175
 
338
- 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. `AcidicJob` 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. `AcidicJob` 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.
339
-
340
- For example, you could use this hook to immediately clean up the `AcidicJob::Run` database record whenever the workflow job finishes successfully like so:
176
+ In my opinion, any commercial software using Sidekiq should get Sidekiq Pro; it is _absolutely_ worth the money. If, however, you are using `acidic_job` in a non-commercial application, you could use the open-source dropin replacement for this functionality: https://github.com/breamware/sidekiq-batch
341
177
 
342
178
  ```ruby
343
- class RideCreateJob < AcidicJob::Base
344
- set_callback :finish, :after, :delete_run_record
179
+ # TODO: write code sample
180
+ class RideCreateJob < ActiveJob::Base
181
+ include AcidicJob
345
182
 
346
183
  def perform(user_id, ride_params)
347
- @user = User.find(user_id)
348
- @params = ride_params
184
+ user = User.find(user_id)
349
185
 
350
- with_acidic_workflow persisting: { ride: nil } do |workflow|
351
- step :create_ride_and_audit_record, awaits: [SomeJob.with('argument_1', keyword: 'value')]
186
+ with_acidity given: { user: user, params: ride_params, ride: nil } do
187
+ step :create_ride_and_audit_record, awaits: [SomeJob]
352
188
  step :create_stripe_charge, args: [1, 2, 3], kwargs: { some: 'thing' }
353
189
  step :send_receipt
354
190
  end
355
191
  end
356
-
357
- private
358
-
359
- def delete_run_record
360
- return unless acidic_job_run.succeeded?
361
-
362
- acidic_job_run.destroy!
363
- end
364
- end
365
- ```
366
-
367
-
368
- ## Testing
369
-
370
- When testing acidic jobs, you are likely to run into `ActiveRecord::TransactionIsolationError`s:
371
-
372
- ```
373
- ActiveRecord::TransactionIsolationError: cannot set transaction isolation in a nested transaction
374
- ```
375
-
376
- 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.
377
-
378
- 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.
379
-
380
- 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.
381
-
382
- 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:
383
-
384
- ```ruby
385
- require "database_cleaner/active_record"
386
-
387
- # see https://github.com/DatabaseCleaner/database_cleaner#how-to-use
388
- RSpec.configure do |config|
389
- config.use_transactional_fixtures = false
390
-
391
- config.before(:suite) do
392
- DatabaseCleaner.clean_with :truncation
393
-
394
- # Here we are defaulting to :transaction but swapping to deletion for some specs;
395
- # if your spec or its code-under-test uses
396
- # nested transactions then specify :transactional e.g.:
397
- # describe "SomeWorker", :transactional do
398
- #
399
- DatabaseCleaner.strategy = :transaction
400
-
401
- config.before(:context, transactional: true) { DatabaseCleaner.strategy = :deletion }
402
- config.after(:context, transactional: true) { DatabaseCleaner.strategy = :transaction }
403
- config.before(:context, type: :system) { DatabaseCleaner.strategy = :deletion }
404
- config.after(:context, type: :system) { DatabaseCleaner.strategy = :transaction }
405
- end
406
-
407
- config.around(:each) do |example|
408
- DatabaseCleaner.cleaning do
409
- example.run
410
- end
411
- end
412
192
  end
413
193
  ```
414
194
 
data/acidic_job.gemspec CHANGED
@@ -27,23 +27,10 @@ Gem::Specification.new do |spec|
27
27
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.add_dependency "activejob"
31
- spec.add_dependency "activerecord"
30
+ spec.add_dependency "activerecord", ">= 6.1.0"
32
31
  spec.add_dependency "activesupport"
33
- spec.add_development_dependency "combustion"
34
- spec.add_development_dependency "minitest"
35
- spec.add_development_dependency "net-smtp"
36
- spec.add_development_dependency "noticed"
37
- spec.add_development_dependency "railties"
38
- spec.add_development_dependency "rake"
39
- spec.add_development_dependency "rubocop"
40
- spec.add_development_dependency "rubocop-minitest"
41
- spec.add_development_dependency "rubocop-rake"
42
- spec.add_development_dependency "sidekiq"
43
- spec.add_development_dependency "simplecov"
44
- spec.add_development_dependency "sqlite3"
32
+ spec.add_development_dependency "railties", ">= 6.1.0"
45
33
 
46
34
  # For more information and examples about making a new gem, checkout our
47
35
  # guide at: https://bundler.io/guides/creating_gem.html
48
- spec.metadata["rubygems_mfa_required"] = "true"
49
36
  end
data/bin/console CHANGED
@@ -7,10 +7,8 @@ require "acidic_job"
7
7
  # You can add fixtures and/or initialization code here to make experimenting
8
8
  # with your gem easier. You can also use a different console, if you like.
9
9
 
10
- require "combustion"
11
- require "sqlite3"
12
- Combustion.path = "test/combustion"
13
- Combustion.initialize! :active_record
10
+ require_relative "../test/support/setup"
11
+ require_relative "../test/support/ride_create_job"
14
12
 
15
13
  # (If you use this, don't forget to add pry to your Gemfile!)
16
14
  # require "pry"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ module Awaiting
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # TODO: Allow the `perform` method to be used to kick off Sidekiq Batch powered workflows
11
+ def initiate(*args)
12
+ raise SidekiqBatchRequired unless defined?(Sidekiq::Batch)
13
+
14
+ top_level_workflow = Sidekiq::Batch.new
15
+ top_level_workflow.on(:success, self, *args)
16
+ top_level_workflow.jobs do
17
+ perform_async
18
+ end
19
+ end
20
+ end
21
+
22
+ def enqueue_step_parallel_jobs(jobs, run, step_result)
23
+ # `batch` is available from Sidekiq::Pro
24
+ raise SidekiqBatchRequired unless defined?(Sidekiq::Batch)
25
+
26
+ batch.jobs do
27
+ step_batch = Sidekiq::Batch.new
28
+ # step_batch.description = "AcidicJob::Workflow Step: #{step}"
29
+ step_batch.on(
30
+ :success,
31
+ "#{self.class.name}#step_done",
32
+ # NOTE: options are marshalled through JSON so use only basic types.
33
+ { "run_id" => run.id,
34
+ "step_result_yaml" => step_result.to_yaml.strip }
35
+ )
36
+ # NOTE: The jobs method is atomic.
37
+ # All jobs created in the block are actually pushed atomically at the end of the block.
38
+ # If an error is raised, none of the jobs will go to Redis.
39
+ step_batch.jobs do
40
+ jobs.each do |worker_name|
41
+ # TODO: handle Symbols as well
42
+ worker = worker_name.is_a?(String) ? worker_name.constantize : worker_name
43
+ if worker.instance_method(:perform).arity.zero?
44
+ worker.perform_async
45
+ elsif worker.instance_method(:perform).arity == 1
46
+ worker.perform_async(run.id)
47
+ else
48
+ raise TooManyParametersForParallelJob
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def step_done(_status, options)
56
+ run = Run.find(options["run_id"])
57
+ current_step = run.workflow[run.recovery_point.to_s]
58
+ # re-hydrate the `step_result` object
59
+ step_result = YAML.safe_load(options["step_result_yaml"], permitted_classes: [RecoveryPoint, FinishedPoint])
60
+ step = Step.new(current_step, run, self, step_result)
61
+
62
+ # TODO: WRITE REGRESSION TESTS FOR PARALLEL JOB FAILING AND RETRYING THE ORIGINAL STEP
63
+ step.progress
64
+ # when a batch of jobs for a step succeeds, we begin processing the `AcidicJob::Run` record again
65
+ process_run(run)
66
+ end
67
+ end
68
+ end
@@ -2,18 +2,26 @@
2
2
 
3
3
  module AcidicJob
4
4
  class Error < StandardError; end
5
- class MissingWorkflowBlock < Error; end
5
+
6
+ class MismatchedIdempotencyKeyAndJobArguments < Error; end
7
+
8
+ class LockedIdempotencyKey < Error; end
9
+
6
10
  class UnknownRecoveryPoint < Error; end
7
- class NoDefinedSteps < Error; end
8
- class RedefiningWorkflow < Error; end
9
- class UndefinedStepMethod < Error; end
10
- class UnknownForEachCollection < Error; end
11
- class UniterableForEachCollection < Error; end
11
+
12
+ class UnknownAtomicPhaseType < Error; end
13
+
14
+ class SerializedTransactionConflict < Error; end
15
+
12
16
  class UnknownJobAdapter < Error; end
13
- class UnknownAwaitedJob < Error; end
17
+
18
+ class NoDefinedSteps < Error; end
19
+
20
+ class SidekiqBatchRequired < Error; end
21
+
14
22
  class TooManyParametersForStepMethod < Error; end
15
- class UnserializableValue < Error; end
16
- class LockedIdempotencyKey < Error; end
17
- class MismatchedIdempotencyKeyAndJobArguments < Error; end
18
- class MissingBlockArgument < Error; end
23
+
24
+ class TooManyParametersForParallelJob < Error; end
25
+
26
+ class UnknownSerializedJobIdentifier < Error; end
19
27
  end
@@ -8,12 +8,20 @@ module AcidicJob
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  def deliver_acidicly(_options = {})
11
- job_class = ::ActionMailer::MailDeliveryJob
11
+ job = ::ActionMailer::MailDeliveryJob
12
+
12
13
  job_args = [@mailer_class.name, @action.to_s, "deliver_now", @params, *@args]
13
- job = job_class.new(job_args)
14
+ # for Sidekiq, this depends on the Sidekiq::Serialization extension
15
+ serialized_job = job.new(job_args).serialize
14
16
 
15
- AcidicJob::Run.stage!(job)
17
+ AcidicJob::Run.create!(
18
+ staged: true,
19
+ job_class: job.name,
20
+ serialized_job: serialized_job,
21
+ idempotency_key: IdempotencyKey.value_for(serialized_job)
22
+ )
16
23
  end
24
+ alias deliver_transactionally deliver_acidicly
17
25
  end
18
26
  end
19
27
  end