chaotic_job 0.0.1 โ†’ 0.1.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: 970f6e59807e48fc6bc71581fe2a12cd0c890429879b079dc8205f73aa74f2d5
4
- data.tar.gz: 3378067f6be89a0f2a715ea60a18ada92af2d430d555d96756c6d3024802b5be
3
+ metadata.gz: d7a3e97903b960aad5ff163a8ea80d3d1c94d8427e86d0d06cb6c44ceffd7ad4
4
+ data.tar.gz: b4c2200a5436d1575f0f66d8647834514bd6563ac3fbf62a74014557d198ed90
5
5
  SHA512:
6
- metadata.gz: 37a614599cc3b971ec6fb93b19d145be3cce82198bd631c945d996c1f1b30f0d0ac0f0bf643649548d780ffeecbb1a237fb5a5051043c394e087c7cbd04f94f8
7
- data.tar.gz: 042ae03b7c80a1fc4570fb0617645f1d3801e331fa73dbd80b8a6bc11529136db4f99d8313bc2f7efb7f5808c66c792a419d8197d81734622cc174bf83bfe76c
6
+ metadata.gz: fa8f9e58bf1fa3eab4e4b6441e91824322e02043daea6fccb7f4d21844c739b34d376880ef03924fed43a7f7d49cf72fa536400698216ca58247cb287c49abd6
7
+ data.tar.gz: f8e83e868e13ed287f1ae9ff3b2560257b5c6fe1e6ba715bf400a93d2148c5a36d9372ac70aaadb5a498db198b3a0f0d9cd4bb3c00d138aa0ac4a8961c1e463a
data/.standard.yml CHANGED
@@ -1,3 +1,7 @@
1
1
  # For available configuration options, see:
2
2
  # https://github.com/standardrb/standard
3
3
  ruby_version: 3.0
4
+
5
+ ignore:
6
+ - 'test/**/*':
7
+ - Lint/ConstantDefinitionInBlock
data/CHANGELOG.md CHANGED
@@ -2,4 +2,13 @@
2
2
 
3
3
  ## [0.1.0] - 2024-11-06
4
4
 
5
+ - Added `Journal` to log activity for tests
6
+ - Added `Performer` to correctly perform jobs with retries
7
+ - Added `Glitch` to inject transient failures into code execution
8
+ - Added `Scenario` to define a glitch for a specific job
9
+ - Added `Simulation` to run all possible error scenarios for a job
10
+ - Added `Helpers` module to provide easy to use methods for testing
11
+
12
+ ## [0.0.1] - 2024-11-06
13
+
5
14
  - Initial release
data/README.md CHANGED
@@ -3,13 +3,16 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/chaotic_job.svg)](https://rubygems.org/gems/chaotic_job)
4
4
  [![Gem Downloads](https://img.shields.io/gem/dt/chaotic_job)](https://rubygems.org/gems/chaotic_job)
5
5
  ![Tests](https://github.com/fractaledmind/chaotic_job/actions/workflows/main.yml/badge.svg)
6
- ![Coverage](https://img.shields.io/badge/code%20coverage-98%25-success)
6
+ ![Coverage](https://img.shields.io/badge/code%20coverage-92%25-success)
7
7
  [![Sponsors](https://img.shields.io/github/sponsors/fractaledmind?color=eb4aaa&logo=GitHub%20Sponsors)](https://github.com/sponsors/fractaledmind)
8
8
  [![Twitter Follow](https://img.shields.io/twitter/url?label=%40fractaledmind&style=social&url=https%3A%2F%2Ftwitter.com%2Ffractaledmind)](https://twitter.com/fractaledmind)
9
9
 
10
- ## Installation
10
+ > [!TIP]
11
+ > This gem helps you test that your Active Jobs are reliable and resilient to failures. If you want to more easily *build* reliable and resilient Active Jobs, check out the companion [Acidic Job](https://github.com/fractaledmind/acidic_job/tree/alpha-1.0) gem.
12
+
13
+ `ChaoticJob` provides a set of tools to help you test the reliability and resilience of your Active Jobs. It does this by allowing you to simulate various types of failures and glitches that can occur in a production environment.
11
14
 
12
- TODO: Replace `chaotic_job` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
15
+ ## Installation
13
16
 
14
17
  Install the gem and add to the application's Gemfile by executing:
15
18
 
@@ -25,7 +28,166 @@ gem install chaotic_job
25
28
 
26
29
  ## Usage
27
30
 
28
- TODO: Write usage instructions here
31
+ `ChaoticJob` should be used primarily by including its helpers into your Active Job tests:
32
+
33
+ ```ruby
34
+ class TestYourJob < ActiveJob::TestCase
35
+ include ChaoticJob::Helpers
36
+
37
+ test "job is reliable" do
38
+ # ...
39
+ end
40
+ end
41
+ ```
42
+
43
+ The `ChaoticJob::Helpers` module provides 6 methods, 4 of which simply allow you to perform a job with retries in the proper way while the other 2 allow you to simulate failures and glitches.
44
+
45
+ ### Performing Jobs
46
+
47
+ When testing job resilience, you will necessarily be testing how a job behaves when it retries. Unfortunately, the helpers provided by `ActiveJob::TestHelper` are tailored to testing the job's behavior on the first attempt.
48
+
49
+ Specifically, when you want to perform a job and all of its retries, you would naturally reach for the [`perform_enqueued_jobs`](https://api.rubyonrails.org/classes/ActiveJob/TestHelper.html#method-i-perform_enqueued_jobs) method.
50
+
51
+ > [!WARNING]
52
+ > Do not use `perform_enqueued_jobs` to test job retries.
53
+
54
+ ```ruby
55
+ perform_enqueued_jobs do
56
+ Job.perform_later
57
+ end
58
+ ```
59
+
60
+ But, this method does not behave as you would expect. Functionally, it overwrites the `enqueue` method to immediately perform the job, which means that instead of your job being performed in waves, the retry is performed _within_ the execution of the original job. This both confuses the logs and means the behavior in your tests are not representative of the behavior in production.
61
+
62
+ In order to properly test job retries, you should use the `perform_all` method provided by `ChaoticJob::Helpers`:
63
+
64
+ ```ruby
65
+ Job.perform_later
66
+ perform_all
67
+ ```
68
+
69
+ This helper will perform the job and all of its retries in the proper way, in waves, just like it would in production.
70
+
71
+ If you need more control over which batches of jobs are performed, you can use the `perform_all_before` and `perform_all_after` methods. These are particularly useful if you need to test the behavior of a job that schedules another job. You can use these methods to perform only the original job and its retries, assert the state of the system, and then perform the scheduled job and its retries.
72
+
73
+ ```ruby
74
+ JobThatSchedules.perform_later
75
+ perform_all_before(4.seconds)
76
+ assert_equal 1, enqueued_jobs.size
77
+ assert_equal 2, performed_jobs.size
78
+
79
+ perform_all_after(1.day)
80
+ assert_equal 0, enqueued_jobs.size
81
+ assert_equal 3, performed_jobs.size
82
+ ```
83
+
84
+ You can pass either a `Time` object or an `ActiveSupport::Duration` object to these methods. And, to make the code as readable as possible, the `perform_all_before` is also aliased as the `perform_all_within` method. This allows you to write the example above as `perform_all_within(4.seconds)`.
85
+
86
+ ### Simulating Failures
87
+
88
+ The helper methods for correctly performing jobs and their retries are useful, but they are not the primary reason for using `ChaoticJob`. The real power of this gem comes from its ability to simulate failures and glitches.
89
+
90
+ The first helper you can use is the `run_scenario` method. A scenario is simply a set of glitches that will be injected into the specified code once. Here is an example:
91
+
92
+ ```ruby
93
+ test "scenario of a simple job" do
94
+ class Job < ActiveJob::Base
95
+ def perform
96
+ step_1
97
+ step_2
98
+ step_3
99
+ end
100
+
101
+ def step_1; ChaoticJob::Journal.log; end
102
+ def step_2; ChaoticJob::Journal.log; end
103
+ def step_3; ChaoticJob::Journal.log; end
104
+ end
105
+
106
+ run_scenario(Job.new, glitch: ["before", "#{__FILE__}:6"])
107
+
108
+ assert_equal 5, ChaoticJob::Journal.total
109
+ end
110
+ ```
111
+
112
+ > [!NOTE]
113
+ > The `ChaoticJob::Journal` class is a simple class that you can use to log things happening. It is used here to track the behavior of the job. It's has a lean, but highly useful, interface:
114
+ > |method|description|
115
+ > |---|---|
116
+ > | `Journal.log` | log simply that something happened within the default scope |
117
+ > | `Journal.log(thing, scope: :special)` | log a particular value within a particular scope |
118
+ > | `Journal.total` | get the total number of logs under the default scope |
119
+ > | `Journal.total(scope: :special)` | get the total number of logs under a particular scope |
120
+ > | `Journal.all` | get all of the logged values under the default scope |
121
+ > | `Journal.all(scope: :special)` | get all of the logged values under a particular scope |
122
+
123
+ In this example, the job being tested is defined within the test case. You can, of course, also test jobs defined in your application. The key detail is the `glitch` keyword argument. A "glitch" is simply a tuple that describes precisely where you would like the failure to occur. The first element of the tuple is the location of the glitch, which can be either *before* or *after*. The second element is the location of the code that will be affected by the glitch, defined by its file path and line number. What this example scenario does is inject a glitch before the `step_3` method is called, here:
124
+
125
+ ```ruby
126
+ def perform
127
+ step_1
128
+ step_2
129
+ # <-- HERE
130
+ step_3
131
+ end
132
+ ```
133
+
134
+ This glitch is a transient error, which are the only kind of errors that matter when testing resilience, as permanent errors mean your job will simply end up in the dead set. So, the glitch failure will occur once and only once, this forces a retry but does not prevent the job from completing.
135
+
136
+ If you want to simulate multiple glitches affecting a job run, you can use the plural `glitches` keyword argument instead and pass an array of tuples:
137
+
138
+ ```ruby
139
+ run_scenario(Job.new, glitches: [
140
+ ["before", "#{__FILE__}:6"],
141
+ ["before", "#{__FILE__}:7"]
142
+ ])
143
+ ```
144
+
145
+ Scenario testing is useful to test the behavior of a job under a specific set of conditions. But, if you want to test the behavior of a job under a variety of conditions, you can use the `run_simulation` method. Instead of running a single scenario, a simulation will run the full set of possible error scenarios for your job.
146
+
147
+ ```ruby
148
+ test "simulation of a simple job" do
149
+ class Job < ActiveJob::Base
150
+ def perform
151
+ step_1
152
+ step_2
153
+ step_3
154
+ end
155
+
156
+ def step_1 = ChaoticJob::Journal.log
157
+ def step_2 = ChaoticJob::Journal.log
158
+ def step_3 = ChaoticJob::Journal.log
159
+ end
160
+
161
+ run_simulation(Job.new) do |scenario|
162
+ assert_operator ChaoticJob::Journal.total, :>=, 3
163
+ end
164
+ end
165
+ ```
166
+
167
+ In this example, the simulation will run 12 scenarios:
168
+
169
+ ```ruby
170
+ [
171
+ [[:after, "test_chaotic_job.rb:69"]],
172
+ [[:before, "test_chaotic_job.rb:75"]],
173
+ [[:after, "test_chaotic_job.rb:74"]],
174
+ [[:before, "test_chaotic_job.rb:74"]],
175
+ [[:after, "test_chaotic_job.rb:68"]],
176
+ [[:after, "test_chaotic_job.rb:70"]],
177
+ [[:before, "test_chaotic_job.rb:68"]],
178
+ [[:after, "test_chaotic_job.rb:73"]],
179
+ [[:after, "test_chaotic_job.rb:75"]],
180
+ [[:before, "test_chaotic_job.rb:69"]],
181
+ [[:before, "test_chaotic_job.rb:70"]],
182
+ [[:before, "test_chaotic_job.rb:73"]]
183
+ ]
184
+ ```
185
+
186
+ It generates all possible glitch scenarios by performing your job once with a [`TracePoint`](https://docs.ruby-lang.org/en/master/TracePoint.html) that captures each line executed in your job. It then computes all possible glitch locations to produce a set of scenarios that will be run.[^1] The block that you pass to `run_simulation` will be called for each scenario, allowing you to make assertions about the behavior of your job under all scenarios.
187
+
188
+ [^1]: The logic to determine all possible glitch locations essentially produces two locations, before and after, for each executed line. It then dedupes the functionally equivalent locations of `[:after, "file:1"]` and `[:before, "file:2"]`.
189
+
190
+ In your application tests, you will want to make assertions about the side-effects that your job performs, asserting that they are correctly idempotent (only occur once) and result in the correct state.
29
191
 
30
192
  ## Development
31
193
 
@@ -35,7 +197,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
35
197
 
36
198
  ## Contributing
37
199
 
38
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/chaotic_job. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/chaotic_job/blob/main/CODE_OF_CONDUCT.md).
200
+ Bug reports and pull requests are welcome on GitHub at https://github.com/fractaledmind/chaotic_job. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/fractaledmind/chaotic_job/blob/main/CODE_OF_CONDUCT.md).
39
201
 
40
202
  ## License
41
203
 
@@ -43,4 +205,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
43
205
 
44
206
  ## Code of Conduct
45
207
 
46
- Everyone interacting in the ChaoticJob project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/chaotic_job/blob/main/CODE_OF_CONDUCT.md).
208
+ Everyone interacting in the ChaoticJob project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fractaledmind/chaotic_job/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -3,7 +3,9 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
- Minitest::TestTask.create
6
+ Minitest::TestTask.create :test do |t|
7
+ t.framework = nil
8
+ end
7
9
 
8
10
  require "standard/rake"
9
11
 
@@ -61,7 +61,7 @@ module ChaoticJob
61
61
  @breakpoints[path_with_line] ||= {}
62
62
  # contents = File.read(file_path).split("\n") unless @file_contents.key?(path_with_line)
63
63
  # @file_contents << contents
64
- @breakpoints[path_with_line][position] = { block: block, executed: false }
64
+ @breakpoints[path_with_line][position] = {block: block, executed: false}
65
65
  end
66
66
 
67
67
  def execute_block(handler)
@@ -1,40 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Performer.new(Job1).perform_all
4
- # Performer.new(Job1).perform_all_within(time)
5
- # Performer.new(Job1).perform_all_after(time)
3
+ require "active_job"
6
4
 
7
5
  module ChaoticJob
8
- class Performer
9
- include ActiveJob::TestHelper
6
+ module Performer
7
+ extend ActiveJob::TestHelper
8
+ extend self
10
9
 
11
- def initialize(job, retry_window: 4)
12
- @job = job
13
- @retry_window = retry_window
10
+ def perform_all
11
+ until (jobs = enqueued_jobs_where).empty?
12
+ perform(jobs)
13
+ end
14
14
  end
15
15
 
16
- def perform_all
17
- @job.enqueue
18
- enqueued_jobs_with.sort_by(&:scheduled_at, nil: :first).each do |job|
19
- perform_job(job)
16
+ def perform_all_before(cutoff)
17
+ time = resolve_cutoff(cutoff)
18
+
19
+ until (jobs = enqueued_jobs_where(before: time)).empty?
20
+ perform(jobs)
20
21
  end
21
22
  end
23
+ alias_method :perform_all_within, :perform_all_before
24
+
25
+ def perform_all_after(cutoff)
26
+ time = resolve_cutoff(cutoff)
22
27
 
23
- def perform_all_after(2.seconds.from_now)
28
+ until (jobs = enqueued_jobs_where(after: time)).empty?
29
+ perform(jobs)
30
+ end
24
31
  end
25
32
 
26
- def perform_all_within(time)
33
+ def perform(jobs)
34
+ jobs.each do |payload|
35
+ queue_adapter.enqueued_jobs.delete(payload)
36
+ queue_adapter.performed_jobs << payload
37
+ instantiate_job(payload, skip_deserialize_arguments: true).perform_now
38
+ end.count
27
39
  end
28
40
 
29
- private
41
+ def enqueued_jobs_where(before: nil, after: nil)
42
+ enqueued_jobs
43
+ .sort_by { |job| job["scheduled_at"] }
44
+ .select do |job|
45
+ scheduled_at = job[:at]
30
46
 
31
- def perform_enqueued_jobs_only_with_retries
32
- retry_window = Time.now + @retry_window
33
- flush_enqueued_jobs(at: retry_window) until enqueued_jobs_with(at: retry_window).empty?
47
+ next true if scheduled_at.nil?
48
+
49
+ # Skip if the job is scheduled after the cutoff time
50
+ if before
51
+ next false if scheduled_at > before.to_f
52
+ end
53
+
54
+ # Skip if the job is scheduled before the cutoff time
55
+ if after
56
+ next false if scheduled_at < after.to_f
57
+ end
58
+
59
+ true
60
+ end
34
61
  end
35
62
 
36
- def perform_any_enqueued_jobs_including_future_scheduled_ones
37
- flush_enqueued_jobs until enqueued_jobs_with.empty?
63
+ def resolve_cutoff(cutoff)
64
+ time = case cutoff
65
+ in ActiveSupport::Duration
66
+ cutoff.from_now
67
+ in Time
68
+ cutoff
69
+ end
70
+ delta = (Time.now - time).abs
71
+ changeset = case delta
72
+ when 0..59 # seconds
73
+ {usec: 0}
74
+ when 60..3599 # minutes
75
+ {sec: 0, usec: 0}
76
+ when 3600..86_399 # hours
77
+ {min: 0, sec: 0, usec: 0}
78
+ when 86_400..Float::INFINITY # days+
79
+ {hour: 0, min: 0, sec: 0, usec: 0}
80
+ end
81
+ time.change(**changeset)
38
82
  end
39
83
  end
40
84
  end
@@ -21,7 +21,12 @@ module ChaoticJob
21
21
 
22
22
  ActiveSupport::Notifications.subscribed(->(event) { @events << event.dup }, @capture) do
23
23
  glitch.inject! do
24
- block_given? ? yield : Performance.rehearse(@job)
24
+ if block_given?
25
+ yield
26
+ else
27
+ @job.enqueue
28
+ Performer.perform_all
29
+ end
25
30
  end
26
31
  end
27
32
 
@@ -6,22 +6,23 @@
6
6
  # Simulation.new(job).scenarios
7
7
  module ChaoticJob
8
8
  class Simulation
9
- def initialize(job, test: nil, variations: 100, seed: nil, depth: 3)
9
+ def initialize(job, depth: 1, variations: 100, test: nil, seed: nil)
10
10
  @template = job
11
- @test = test
11
+ @depth = depth
12
12
  @variations = variations
13
+ @test = test
13
14
  @seed = seed || Random.new_seed
14
15
  @random = Random.new(@seed)
15
- @depth = depth
16
16
  end
17
17
 
18
18
  def run(&callback)
19
19
  @template.class.retry_on RetryableError, attempts: @depth + 2, wait: 1, jitter: 0
20
20
 
21
- debug "Running #{variants.size} simulations of the total #{permutations.size} possibilities..."
21
+ debug "๐Ÿ‘พ Running #{variants.size} simulations of the total #{permutations.size} possibilities..."
22
22
 
23
23
  scenarios.map do |scenario|
24
24
  run_scenario(scenario, &callback)
25
+ print "ยท"
25
26
  end
26
27
  end
27
28
 
@@ -70,7 +71,7 @@ module ChaoticJob
70
71
  trace = TracePoint.new(:line) do |tp|
71
72
  next if tp.defined_class == self.class
72
73
  next unless tp.path == job_file_path ||
73
- tp.defined_class == job_class
74
+ tp.defined_class == job_class
74
75
 
75
76
  @callstack << [tp.path, tp.lineno]
76
77
  end
@@ -81,9 +82,9 @@ module ChaoticJob
81
82
  end
82
83
 
83
84
  def run_scenario(scenario, &callback)
84
- debug "Running simulation with scenario: #{scenario}"
85
+ debug "๐Ÿ‘พ Running simulation with scenario: #{scenario}"
85
86
  @test.before_setup
86
- scenario.enact!
87
+ scenario.run
87
88
  @test.after_teardown
88
89
  callback.call(scenario)
89
90
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChaoticJob
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/chaotic_job.rb CHANGED
@@ -1,17 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "chaotic_job/version"
4
+ require_relative "chaotic_job/journal"
5
+ require_relative "chaotic_job/performer"
6
+ require_relative "chaotic_job/glitch"
7
+ require_relative "chaotic_job/scenario"
8
+ require_relative "chaotic_job/simulation"
4
9
 
5
10
  module ChaoticJob
6
- class RetryableError < StandardError; end
11
+ class RetryableError < StandardError
12
+ end
7
13
 
8
14
  module Helpers
9
- def run_simulation(job, ..., &block)
10
- Simulation.new(job, test: self, ...).run(&block)
15
+ def perform_all
16
+ Performer.perform_all
17
+ end
18
+
19
+ def perform_all_within(time)
20
+ Performer.perform_all_within(time)
21
+ end
22
+
23
+ def perform_all_before(time)
24
+ Performer.perform_all_before(time)
25
+ end
26
+
27
+ def perform_all_after(time)
28
+ Performer.perform_all_after(time)
29
+ end
30
+
31
+ def run_simulation(job, depth: nil, variations: nil, &block)
32
+ seed = defined?(RSpec) ? RSpec.configuration.seed : Minitest.seed
33
+ kwargs = {test: self, seed: seed}
34
+ kwargs[:depth] = depth if depth
35
+ kwargs[:variations] = variations if variations
36
+ Simulation.new(job, **kwargs).run(&block)
11
37
  end
12
38
 
13
- def run_scenario(job)
14
- Scenario.new(job).run
39
+ def run_scenario(job, glitch: nil, glitches: nil, raise: nil, capture: nil)
40
+ kwargs = {glitches: glitches || [glitch]}
41
+ kwargs[:raise] = raise if raise
42
+ kwargs[:capture] = capture if capture
43
+ Scenario.new(job, **kwargs).run
15
44
  end
16
45
  end
17
46
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chaotic_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
@@ -9,7 +9,21 @@ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
11
  date: 2024-11-07 00:00:00.000000000 Z
12
- dependencies: []
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
13
27
  description:
14
28
  email:
15
29
  - stephen.margheim@gmail.com