chaotic_job 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8241f1235f37ed46c1da18d53e18c6fce43fb5758273bb51dba8db332dddb5b6
4
- data.tar.gz: fc86ee73cf18b16d6142f7111e5d1cfa446b46bfea2e1959eb7aa222a5bf42dd
3
+ metadata.gz: 98b01482666de5069e40dbe4eeb828cb527e4dd31004b3900a134ceaec5cb42a
4
+ data.tar.gz: f3df1be0fa3192aa842b74fa56754e56ed071f48d3c2fd027ca88d05da3f5e90
5
5
  SHA512:
6
- metadata.gz: 7d32f1ab9748ea544d13c15692934634a6c3bcc01d602903e5e554abd229b7623a6a9aff111357031f474ec701fcd96131994604a121f9e55672eaed2616107b
7
- data.tar.gz: c8ef73d8d07a091f59ebd1347ecc3f11fbd6ba6abc3eee218e251600ff156f748ec2a911d8108834cad96f9ce7ef59d1b3989606a27a827f1bb7bd93bfd9b062
6
+ metadata.gz: aff436f38402fddae8e62a7dbb441dc8949169abd1aa992fdf5e59f33e007f8dc3aba29787a3cea4bf3d421698ed1ac76c171388c26a3d2ab884e7880296ba84
7
+ data.tar.gz: 72986fa7274aade0e1fe7f9a5b71e4911467f18bf4929b90960ba62b03721611e46da8958c0536a92f92e98f4b4a2c7315e674db73bbcc6866cc177070a6f92f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2025-05-27
4
+
5
+ - Allow a Glitch to be defined for a method call or method return [#4](https://github.com/fractaledmind/chaotic_job/pull/4)
6
+
7
+ ## [0.3.0] - 2024-12-17
8
+
9
+ - Ensure that assertion failure messages raised within a simulation contain the scenario description
10
+ - Add a `ChaoticJob.journal_entries` top-level method
11
+
3
12
  ## [0.2.0] - 2024-11-06
4
13
 
5
14
  - Update the `perform_all` helper method to `perform_all_jobs`
data/README.md CHANGED
@@ -42,6 +42,10 @@ end
42
42
 
43
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
44
 
45
+ ### Glitches
46
+
47
+ A central concept in `ChaoticJob` is the _glitch_. A glitch is an error injected into the job execution flow via a [`TracePoint`](https://docs.ruby-lang.org/en/master/TracePoint.html). Glitches are transient errors, which means they occur _once_ and **only once**, making them perfect for testing a job's resilience to unpredictable failures that can occur while running jobs, like network issues, upstream API outages, rate limits, or infrastructure failure. By default, `ChaoticJob` raises a custom error defined by the gem (`ChaoticJob::RetryableError`), which the internals of the gem ensure that the job under test is configured to retry on; you can, however, raise specific errors as needed when setting up your [scenarios](#simulating-failures). By forcing a retry via the error handling mechanisms of Active Job, glitches are a simple but effective way to test that your job is resilient to any kind of transient error that the job is configured to retry on.
48
+
45
49
  ### Performing Jobs
46
50
 
47
51
  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.
@@ -103,7 +107,7 @@ test "scenario of a simple job" do
103
107
  def step_3; ChaoticJob::Journal.log; end
104
108
  end
105
109
 
106
- run_scenario(Job.new, glitch: ["before", "#{__FILE__}:6"])
110
+ run_scenario(Job.new, glitch: [:before_call, "Job#step_3"])
107
111
 
108
112
  assert_equal 5, ChaoticJob::Journal.total
109
113
  end
@@ -120,7 +124,16 @@ end
120
124
  > | `Journal.entries` | get all of the logged values under the default scope |
121
125
  > | `Journal.entries(scope: :special)` | get all of the logged values under a particular scope |
122
126
 
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:
127
+ 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 _kind_ of glitch, which can be either `:before_line`, `:before_call`, or `:before_return`. These refer to the three kinds of `TracePoint` events that the gem hooks into. The second element is the _key_ for the code that will be affected by the glitch. This _key_ is a specially formatted string that defines the specific bit of code that the glitch should be inserted before. The different kinds of glitches are identified by different kinds of keys:
128
+ |kind|key format|key example|
129
+ |---|---|---|
130
+ |`:before_line`|`"#{file_path}:#{line_number}"`|`"/Users/you/path/to/file.rb:123"`|
131
+ |`:before_call`|`"#{YourClass.name}(.|#)#{method_name}"`|`"YourClass.some_class_method"`|
132
+ |`:before_return`|`"#{YourClass.name}(.|#)#{method_name}"`|`"YourClass#some_instance_method"`|
133
+
134
+ As you can see, the `:before_call` and `:before_return` keys are formatted the same, and can identify any instance (`#`) or class (`.`) method.
135
+
136
+ What the example scenario above does is inject a glitch before the `step_3` method is called, here:
124
137
 
125
138
  ```ruby
126
139
  def perform
@@ -131,14 +144,33 @@ def perform
131
144
  end
132
145
  ```
133
146
 
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.
147
+ If we wanted to inject a glitch right before the `step_3` method finishes, we could define the glitch as a `:before_return`, like this:
148
+
149
+ ```ruby
150
+ run_scenario(Job.new, glitch: [:before_return, "Job#step_3"])
151
+ ```
152
+
153
+ and it would inject the transient error right here:
154
+
155
+ ```ruby
156
+ def step_3
157
+ ChaoticJob::Journal.log
158
+ # <-- HERE
159
+ end
160
+ ```
161
+
162
+ Finally, if you need to inject a glitch right before a particular line of code is executed that is neither a method call nor a method return, you can use the `:before_line` key, like this:
163
+
164
+ ```ruby
165
+ run_scenario(Job.new, glitch: [:before_line, "#{__FILE__}:6"])
166
+ ```
135
167
 
136
168
  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
169
 
138
170
  ```ruby
139
171
  run_scenario(Job.new, glitches: [
140
- ["before", "#{__FILE__}:6"],
141
- ["before", "#{__FILE__}:7"]
172
+ [:before_call, "Job#step_1"],
173
+ [:before_return, "Job#step_1"]
142
174
  ])
143
175
  ```
144
176
 
@@ -164,28 +196,26 @@ test "simulation of a simple job" do
164
196
  end
165
197
  ```
166
198
 
167
- In this example, the simulation will run 12 scenarios:
199
+ More specifically, it will create a scenario injecting a glitch before every line of code executed in your job. So, in this example, the simulation will run 12 scenarios:
168
200
 
169
201
  ```ruby
170
202
  [
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"]]
203
+ [[:before_line, "test_chaotic_job.rb:69"]],
204
+ [[:before_line, "test_chaotic_job.rb:75"]],
205
+ [[:before_line, "test_chaotic_job.rb:74"]],
206
+ [[:before_line, "test_chaotic_job.rb:74"]],
207
+ [[:before_line, "test_chaotic_job.rb:68"]],
208
+ [[:before_line, "test_chaotic_job.rb:70"]],
209
+ [[:before_line, "test_chaotic_job.rb:68"]],
210
+ [[:before_line, "test_chaotic_job.rb:73"]],
211
+ [[:before_line, "test_chaotic_job.rb:75"]],
212
+ [[:before_line, "test_chaotic_job.rb:69"]],
213
+ [[:before_line, "test_chaotic_job.rb:70"]],
214
+ [[:before_line, "test_chaotic_job.rb:73"]]
183
215
  ]
184
216
  ```
185
217
 
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"]`.
218
+ 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. 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.
189
219
 
190
220
  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.
191
221
 
@@ -1,46 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Glitch.new.before("job_crucible.rb:10") { do_anything }
4
- # Glitch.new.after("job_crucible.rb:11") { do_anything }
3
+ # Glitch.new.before_line("job_crucible.rb:10") { do_anything }
4
+ # Glitch.new.before_call("Model#method", String, name: "Joel") { do_anything }
5
+ # Glitch.new.before_return("Model#method", String, name: "Joel") { do_anything }
5
6
  # Glitch.new.inject! { execute code to glitch }
6
7
 
7
8
  module ChaoticJob
8
9
  class Glitch
9
10
  def initialize
10
11
  @breakpoints = {}
11
- @file_contents = {}
12
12
  end
13
13
 
14
- def before(path_with_line, &block)
15
- set_breakpoint(path_with_line, :before, &block)
14
+ def before_line(key, &block)
15
+ set_breakpoint(key, :line, &block)
16
+ self
16
17
  end
17
18
 
18
- def after(path_with_line, &block)
19
- set_breakpoint(path_with_line, :after, &block)
19
+ def before_call(key, ...)
20
+ set_breakpoint(key, :call, ...)
21
+ self
20
22
  end
21
23
 
22
- def inject!
23
- prev_key = nil
24
- trace = TracePoint.new(:line) do |tp|
25
- key = "#{tp.path}:#{tp.lineno}"
26
- # content = @file_contents[tp.path]
27
- # line = content[tp.lineno - 1]
28
- # next unless line.match? key
24
+ def before_return(key, return_type = nil, &block)
25
+ set_breakpoint(key, :return, retval: return_type, &block)
26
+ self
27
+ end
29
28
 
30
- begin
31
- execute_block(@breakpoints[prev_key][:after]) if prev_key && @breakpoints.key?(prev_key)
29
+ def inject!(&block)
30
+ breakpoints = @breakpoints
32
31
 
33
- execute_block(@breakpoints[key][:before]) if @breakpoints.key?(key)
34
- ensure
35
- prev_key = key
36
- end
32
+ trace = TracePoint.new(:line, :call, :return) do |tp|
33
+ # :nocov: SimpleCov cannot track code executed _within_ a TracePoint
34
+ key = derive_key(tp)
35
+ matchers = derive_matchers(tp)
36
+
37
+ next unless (defn = breakpoints.dig(key, tp.event))
38
+ next unless matches?(defn, matchers)
39
+
40
+ execute_block(defn)
41
+ # :nocov:
37
42
  end
38
43
 
39
- trace.enable
40
- yield if block_given?
41
- ensure
42
- trace.disable
43
- execute_block(@breakpoints[prev_key][:after]) if prev_key && @breakpoints.key?(prev_key)
44
+ trace.enable(&block)
44
45
  end
45
46
 
46
47
  def all_executed?
@@ -57,11 +58,51 @@ module ChaoticJob
57
58
 
58
59
  private
59
60
 
60
- def set_breakpoint(path_with_line, position, &block)
61
- @breakpoints[path_with_line] ||= {}
62
- # contents = File.read(file_path).split("\n") unless @file_contents.key?(path_with_line)
63
- # @file_contents << contents
64
- @breakpoints[path_with_line][position] = {block: block, executed: false}
61
+ def set_breakpoint(key, event, *args, retval: nil, **kwargs, &block)
62
+ @breakpoints[key] ||= {}
63
+ @breakpoints[key][event] = {args: args, kwargs: kwargs, retval: retval, block: block, executed: false}
64
+ end
65
+
66
+ # :nocov: SimpleCov cannot track code executed _within_ a TracePoint
67
+ def matches?(defn, matchers)
68
+ return true if defn.nil?
69
+ return true if matchers.nil?
70
+ return true if defn[:args].empty? && defn[:kwargs].empty? && defn[:retval].nil?
71
+
72
+ args = []
73
+ kwargs = {}
74
+ retval = nil
75
+
76
+ matchers.each do |kind, name, value|
77
+ case kind
78
+ when :req
79
+ args << value
80
+ when :opt
81
+ args << value if value
82
+ when :rest
83
+ args.concat(value) if value
84
+ when :keyreq
85
+ kwargs[name] = value
86
+ when :key
87
+ kwargs[name] = value
88
+ when :keyrest
89
+ kwargs.merge!(value) if value
90
+ when :retval
91
+ retval = value
92
+ end
93
+ end
94
+
95
+ defn[:args].each_with_index do |type, index|
96
+ return false unless type === args[index]
97
+ end
98
+
99
+ defn[:kwargs].each do |key, type|
100
+ return false unless type === kwargs[key]
101
+ end
102
+
103
+ return false unless defn[:retval] === retval
104
+
105
+ true
65
106
  end
66
107
 
67
108
  def execute_block(handler)
@@ -71,5 +112,33 @@ module ChaoticJob
71
112
  handler[:executed] = true
72
113
  handler[:block].call
73
114
  end
115
+
116
+ def derive_key(trace)
117
+ case trace.event
118
+ when :line
119
+ "#{trace.path}:#{trace.lineno}"
120
+ when :call, :return
121
+ if Module === trace.self
122
+ "#{trace.self}.#{trace.method_id}"
123
+ else
124
+ "#{trace.defined_class}##{trace.method_id}"
125
+ end
126
+ end
127
+ end
128
+
129
+ def derive_matchers(trace)
130
+ case trace.event
131
+ when :line
132
+ nil
133
+ when :call
134
+ trace.parameters.map do |type, name|
135
+ value = trace.binding.local_variable_get(name) rescue nil # standard:disable Style/RescueModifier
136
+ [type, name, value]
137
+ end
138
+ when :return
139
+ [[:retval, nil, trace.return_value]]
140
+ end
141
+ end
142
+ # :nocov:
74
143
  end
75
144
  end
@@ -18,6 +18,7 @@ module ChaoticJob
18
18
  @logs ||= {}
19
19
  @logs[scope] ||= []
20
20
  @logs[scope] << item
21
+ item
21
22
  end
22
23
 
23
24
  def size(scope: :default)
@@ -29,7 +30,7 @@ module ChaoticJob
29
30
  end
30
31
 
31
32
  def top(scope: :default)
32
- entries&.first
33
+ entries(scope: scope)&.first
33
34
  end
34
35
  end
35
36
  end
@@ -10,7 +10,7 @@ module ChaoticJob
10
10
  def initialize(job, glitches:, raise: RetryableError, capture: /active_job/)
11
11
  @job = job
12
12
  @glitches = glitches
13
- @raise = raise
13
+ @raise = binding.local_variable_get(:raise)
14
14
  @capture = capture
15
15
  @glitch = nil
16
16
  @events = []
@@ -45,8 +45,8 @@ module ChaoticJob
45
45
 
46
46
  def glitch
47
47
  @glitch ||= Glitch.new.tap do |glitch|
48
- @glitches.each do |position, location, _description|
49
- glitch.public_send(position, location) { raise @raise }
48
+ @glitches.each do |kind, location, _description|
49
+ glitch.public_send(kind, location) { raise @raise }
50
50
  end
51
51
  end
52
52
  end
@@ -27,20 +27,10 @@ module ChaoticJob
27
27
  end
28
28
 
29
29
  def permutations
30
- callstack = capture_callstack.to_a
31
- error_locations = callstack.each_cons(2).flat_map do |left, right|
32
- lpath, lno = left
33
- rpath, rno = right
34
- key = "#{lpath}:#{lno}"
35
- # inject an error before and after each non-adjacent line
36
- if lpath == rpath && rno == lno + 1
37
- [[:before, key]]
38
- else
39
- [[:before, key], [:after, key]]
40
- end
30
+ callstack = capture_callstack.map { |path, line| "#{path}:#{line}" }
31
+ error_locations = callstack.map do |path, lineno|
32
+ [:before_line, "#{path}:#{lineno}"]
41
33
  end
42
- final_key = callstack.last.join(":")
43
- error_locations.push [:before, final_key], [:after, final_key]
44
34
  error_locations.permutation(@depth)
45
35
  end
46
36
 
@@ -84,9 +74,12 @@ module ChaoticJob
84
74
  def run_scenario(scenario, &callback)
85
75
  debug "👾 Running simulation with scenario: #{scenario}"
86
76
  @test.before_setup
77
+ @test.simulation_scenario = scenario.to_s
87
78
  scenario.run
88
79
  @test.after_teardown
89
80
  callback.call(scenario)
81
+ ensure
82
+ @test.simulation_scenario = nil
90
83
  end
91
84
 
92
85
  def clone_job_template
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChaoticJob
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/chaotic_job.rb CHANGED
@@ -23,6 +23,14 @@ module ChaoticJob
23
23
  end
24
24
  end
25
25
 
26
+ def self.journal_entries(scope: nil)
27
+ if scope
28
+ Journal.entries(scope: scope)
29
+ else
30
+ Journal.entries
31
+ end
32
+ end
33
+
26
34
  def self.journal_size(scope: nil)
27
35
  if scope
28
36
  Journal.size(scope: scope)
@@ -40,6 +48,8 @@ module ChaoticJob
40
48
  end
41
49
 
42
50
  module Helpers
51
+ attr_accessor :simulation_scenario
52
+
43
53
  def perform_all_jobs
44
54
  Performer.perform_all
45
55
  end
@@ -58,12 +68,13 @@ module ChaoticJob
58
68
  kwargs = {test: self, seed: seed}
59
69
  kwargs[:depth] = depth if depth
60
70
  kwargs[:variations] = variations if variations
71
+ self.simulation_scenario = nil
61
72
  Simulation.new(job, **kwargs).run(&block)
62
73
  end
63
74
 
64
75
  def run_scenario(job, glitch: nil, glitches: nil, raise: nil, capture: nil, &block)
65
76
  kwargs = {glitches: glitches || [glitch]}
66
- kwargs[:raise] = raise if raise
77
+ kwargs[:raise] = binding.local_variable_get(:raise) if binding.local_variable_get(:raise)
67
78
  kwargs[:capture] = capture if capture
68
79
  if block
69
80
  Scenario.new(job, **kwargs).run(&block)
@@ -71,5 +82,19 @@ module ChaoticJob
71
82
  Scenario.new(job, **kwargs).run
72
83
  end
73
84
  end
85
+
86
+ def assert(test, msg = nil)
87
+ return super unless @simulation_scenario
88
+
89
+ contextual_msg = lambda do
90
+ # copied from the original `assert` method in Minitest::Assertions
91
+ default_msg = "Expected #{mu_pp test} to be truthy."
92
+ custom_msg = msg.is_a?(Proc) ? msg.call : msg
93
+ full_msg = custom_msg || default_msg
94
+ " #{@simulation_scenario}\n#{full_msg}"
95
+ end
96
+
97
+ super(test, contextual_msg)
98
+ end
74
99
  end
75
100
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chaotic_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-11-07 00:00:00.000000000 Z
10
+ date: 2025-05-27 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activejob
@@ -24,7 +23,6 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '7.0'
27
- description:
28
26
  email:
29
27
  - stephen.margheim@gmail.com
30
28
  executables: []
@@ -52,7 +50,6 @@ metadata:
52
50
  homepage_uri: https://github.com/fractaledmind/chaotic_job
53
51
  source_code_uri: https://github.com/fractaledmind/chaotic_job
54
52
  changelog_uri: https://github.com/fractaledmind/chaotic_job/CHANGELOG.md
55
- post_install_message:
56
53
  rdoc_options: []
57
54
  require_paths:
58
55
  - lib
@@ -67,8 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
64
  - !ruby/object:Gem::Version
68
65
  version: '0'
69
66
  requirements: []
70
- rubygems_version: 3.5.21
71
- signing_key:
67
+ rubygems_version: 3.6.3
72
68
  specification_version: 4
73
69
  summary: Test ActiveJobs for reliability and resilience.
74
70
  test_files: []