chaotic_job 0.3.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: c84ddd59307328e8986c00f5c03da961e4d3910c4e42eb81f847e2703f2fe128
4
- data.tar.gz: 9d65a1efddcb025569d5f97c8f87d3e5a920fee0c40f3b6d2bedf3f8c907b857
3
+ metadata.gz: 98b01482666de5069e40dbe4eeb828cb527e4dd31004b3900a134ceaec5cb42a
4
+ data.tar.gz: f3df1be0fa3192aa842b74fa56754e56ed071f48d3c2fd027ca88d05da3f5e90
5
5
  SHA512:
6
- metadata.gz: e1fe18d93d38e9e15ecb011fc578b94543b2fd8c83c017d7df3f69d0ad243d7f676e8892562c346b07e0b86631891d16465f358c5c5999b639191cad34796543
7
- data.tar.gz: 6837ab188e03fc32cb66feb689f561bc3a7e37dd98bbdff412504876f713d6d1ac4721b4cd4d0bd217a0423cfe938064b441203cd70b60f8c2bf1fe66b687502
6
+ metadata.gz: aff436f38402fddae8e62a7dbb441dc8949169abd1aa992fdf5e59f33e007f8dc3aba29787a3cea4bf3d421698ed1ac76c171388c26a3d2ab884e7880296ba84
7
+ data.tar.gz: 72986fa7274aade0e1fe7f9a5b71e4911467f18bf4929b90960ba62b03721611e46da8958c0536a92f92e98f4b4a2c7315e674db73bbcc6866cc177070a6f92f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## [0.3.0] - 2024-12-17
4
8
 
5
9
  - Ensure that assertion failure messages raised within a simulation contain the scenario description
data/README.md CHANGED
@@ -44,7 +44,7 @@ The `ChaoticJob::Helpers` module provides 6 methods, 4 of which simply allow you
44
44
 
45
45
  ### Glitches
46
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.
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
48
 
49
49
  ### Performing Jobs
50
50
 
@@ -107,7 +107,7 @@ test "scenario of a simple job" do
107
107
  def step_3; ChaoticJob::Journal.log; end
108
108
  end
109
109
 
110
- run_scenario(Job.new, glitch: ["before", "#{__FILE__}:6"])
110
+ run_scenario(Job.new, glitch: [:before_call, "Job#step_3"])
111
111
 
112
112
  assert_equal 5, ChaoticJob::Journal.total
113
113
  end
@@ -124,7 +124,16 @@ end
124
124
  > | `Journal.entries` | get all of the logged values under the default scope |
125
125
  > | `Journal.entries(scope: :special)` | get all of the logged values under a particular scope |
126
126
 
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 location of the glitch, which can be either *before* or *after* a line of code. 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:
128
137
 
129
138
  ```ruby
130
139
  def perform
@@ -135,12 +144,33 @@ def perform
135
144
  end
136
145
  ```
137
146
 
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
+ ```
167
+
138
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:
139
169
 
140
170
  ```ruby
141
171
  run_scenario(Job.new, glitches: [
142
- ["before", "#{__FILE__}:6"],
143
- ["before", "#{__FILE__}:7"]
172
+ [:before_call, "Job#step_1"],
173
+ [:before_return, "Job#step_1"]
144
174
  ])
145
175
  ```
146
176
 
@@ -166,28 +196,26 @@ test "simulation of a simple job" do
166
196
  end
167
197
  ```
168
198
 
169
- 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:
170
200
 
171
201
  ```ruby
172
202
  [
173
- [[:after, "test_chaotic_job.rb:69"]],
174
- [[:before, "test_chaotic_job.rb:75"]],
175
- [[:after, "test_chaotic_job.rb:74"]],
176
- [[:before, "test_chaotic_job.rb:74"]],
177
- [[:after, "test_chaotic_job.rb:68"]],
178
- [[:after, "test_chaotic_job.rb:70"]],
179
- [[:before, "test_chaotic_job.rb:68"]],
180
- [[:after, "test_chaotic_job.rb:73"]],
181
- [[:after, "test_chaotic_job.rb:75"]],
182
- [[:before, "test_chaotic_job.rb:69"]],
183
- [[:before, "test_chaotic_job.rb:70"]],
184
- [[: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"]]
185
215
  ]
186
216
  ```
187
217
 
188
- 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.
189
-
190
- [^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.
191
219
 
192
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.
193
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChaoticJob
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/chaotic_job.rb CHANGED
@@ -74,7 +74,7 @@ module ChaoticJob
74
74
 
75
75
  def run_scenario(job, glitch: nil, glitches: nil, raise: nil, capture: nil, &block)
76
76
  kwargs = {glitches: glitches || [glitch]}
77
- kwargs[:raise] = raise if raise
77
+ kwargs[:raise] = binding.local_variable_get(:raise) if binding.local_variable_get(:raise)
78
78
  kwargs[:capture] = capture if capture
79
79
  if block
80
80
  Scenario.new(job, **kwargs).run(&block)
@@ -89,7 +89,7 @@ module ChaoticJob
89
89
  contextual_msg = lambda do
90
90
  # copied from the original `assert` method in Minitest::Assertions
91
91
  default_msg = "Expected #{mu_pp test} to be truthy."
92
- custom_msg = original_msg.is_a?(Proc) ? original_msg.call : original_msg
92
+ custom_msg = msg.is_a?(Proc) ? msg.call : msg
93
93
  full_msg = custom_msg || default_msg
94
94
  " #{@simulation_scenario}\n#{full_msg}"
95
95
  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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
- original_platform: ''
7
6
  authors:
8
7
  - Stephen Margheim
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-12-17 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
@@ -65,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
65
64
  - !ruby/object:Gem::Version
66
65
  version: '0'
67
66
  requirements: []
68
- rubygems_version: 3.6.0
67
+ rubygems_version: 3.6.3
69
68
  specification_version: 4
70
69
  summary: Test ActiveJobs for reliability and resilience.
71
70
  test_files: []