chaotic_job 0.4.0 → 0.6.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: 98b01482666de5069e40dbe4eeb828cb527e4dd31004b3900a134ceaec5cb42a
4
- data.tar.gz: f3df1be0fa3192aa842b74fa56754e56ed071f48d3c2fd027ca88d05da3f5e90
3
+ metadata.gz: 10db3cce1a0eedffe044477a348122d781e19cfbb6f561c9e389926d7de21153
4
+ data.tar.gz: a48f0d63526613593a25cc92d1c3eca794adb99466ef697a3c932e581a08434a
5
5
  SHA512:
6
- metadata.gz: aff436f38402fddae8e62a7dbb441dc8949169abd1aa992fdf5e59f33e007f8dc3aba29787a3cea4bf3d421698ed1ac76c171388c26a3d2ab884e7880296ba84
7
- data.tar.gz: 72986fa7274aade0e1fe7f9a5b71e4911467f18bf4929b90960ba62b03721611e46da8958c0536a92f92e98f4b4a2c7315e674db73bbcc6866cc177070a6f92f
6
+ metadata.gz: a1ea02d93adf652616f25478c8d7f628f30d56137485b10988bd2f08b9a62153327cd60c787555d7ba4286a7c26f94616ee45c92a58778e0689011461a39bb3b
7
+ data.tar.gz: 6d94662caef5559bca3de08c69674ad10dd4fa349fbd8434a42dc65c8baa8ba6da13f6cc8d13b9af815f4610148a874ba3f9fa0b9e5c8aa97e7b131afc5c3689
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2025-06-08
4
+
5
+ - `run_scenario` requires a Glitch instance [#5](https://github.com/fractaledmind/chaotic_job/pull/5)
6
+
7
+ ## [0.5.0] - 2025-06-04
8
+
9
+ - Add a Tracer class [#3](https://github.com/fractaledmind/chaotic_job/pull/3)
10
+
3
11
  ## [0.4.0] - 2025-05-27
4
12
 
5
13
  - Allow a Glitch to be defined for a method call or method return [#4](https://github.com/fractaledmind/chaotic_job/pull/4)
data/README.md CHANGED
@@ -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_call, "Job#step_3"])
110
+ run_scenario(Job.new, glitch: ChaoticJob::Glitch.before_call("Job#step_3"))
111
111
 
112
112
  assert_equal 5, ChaoticJob::Journal.total
113
113
  end
@@ -124,14 +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 _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:
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.
128
+
129
+ A "glitch" is describes precisely where you would like the failure to occur. The description is composed first of 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
130
  |kind|key format|key example|
129
131
  |---|---|---|
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"`|
132
+ |`before_line`|`"#{file_path}:#{line_number}"`|`"/Users/you/path/to/file.rb:123"`|
133
+ |`before_call`|`"#{YourClass.name}(.|#)#{method_name}"`|`"YourClass.some_class_method"`|
134
+ |`before_return`|`"#{YourClass.name}(.|#)#{method_name}"`|`"YourClass#some_instance_method"`|
133
135
 
134
- As you can see, the `:before_call` and `:before_return` keys are formatted the same, and can identify any instance (`#`) or class (`.`) method.
136
+ As you can see, the `before_call` and `before_return` keys are formatted the same, and can identify any instance (`#`) or class (`.`) method.
135
137
 
136
138
  What the example scenario above does is inject a glitch before the `step_3` method is called, here:
137
139
 
@@ -144,10 +146,10 @@ def perform
144
146
  end
145
147
  ```
146
148
 
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:
149
+ 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
150
 
149
151
  ```ruby
150
- run_scenario(Job.new, glitch: [:before_return, "Job#step_3"])
152
+ run_scenario(Job.new, glitch: ChaoticJob::Glitch.before_return("Job#step_3"))
151
153
  ```
152
154
 
153
155
  and it would inject the transient error right here:
@@ -159,19 +161,21 @@ def step_3
159
161
  end
160
162
  ```
161
163
 
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:
164
+ 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
165
 
164
166
  ```ruby
165
- run_scenario(Job.new, glitch: [:before_line, "#{__FILE__}:6"])
167
+ run_scenario(Job.new, glitch: ChaoticJob::Glitch.before_line("#{__FILE__}:6"))
166
168
  ```
167
169
 
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:
170
+ If you want to simulate multiple glitches affecting a job run, you simply define additional failure points using the fluid interface of the `ChaoticJob::Glitch` class:
169
171
 
170
172
  ```ruby
171
- run_scenario(Job.new, glitches: [
172
- [:before_call, "Job#step_1"],
173
- [:before_return, "Job#step_1"]
174
- ])
173
+ run_scenario(
174
+ Job.new,
175
+ glitch: ChaoticJob::Glitch
176
+ .before_call("Job#step_1")
177
+ .before_return("Job#step_3")
178
+ )
175
179
  ```
176
180
 
177
181
  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.
@@ -217,7 +221,24 @@ More specifically, it will create a scenario injecting a glitch before every lin
217
221
 
218
222
  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.
219
223
 
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.
224
+ If you want to have the simulation run against a larger collection of scenarios, you can capture a custom callstack using the `ChaoticJob::Tracer` class and pass it to the `run_simulation` method as the `callstack` parameter. A `Tracer` is initialized with a block that determines which `TracePoint` events to collect. You then call `capture` with a block that defines the code to be traced. The default `Simulation` tracer collects all events for the passed job and then traces the job execution, essentially like this:
225
+
226
+ ```ruby
227
+ job_file_path = YourJob.instance_method(:perform).source_location&.first
228
+ tracer = Tracer.new { |tp| tp.path == job_file_path || tp.defined_class == YourJob }
229
+ tracer.capture { YourJob.perform_now }
230
+ ```
231
+
232
+ To capture, for example, a custom callstack that includes all events within your application, you can use the `ChaoticJob::Tracer` class as follows:
233
+
234
+ ```ruby
235
+ tracer = ChaoticJob::Tracer.new { |tp| tp.path.start_with?(Rails.root.to_s) }
236
+ tracer.capture { YourJob.perform_now }
237
+ ```
238
+
239
+ If you passed this callstack to your simulation, it would test what happens to your job whenever a transient glitch is injected anywhere in your application code called as a part of executing the job under test.
240
+
241
+ Remember, 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.
221
242
 
222
243
  ## Development
223
244
 
@@ -1,12 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
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 }
6
- # Glitch.new.inject! { execute code to glitch }
3
+ # Glitch.before_line("job_crucible.rb:10") { do_anything }
4
+ # Glitch.before_call("Model#method", String, name: "Joel") { do_anything }
5
+ # Glitch.before_return("Model#method", String, name: "Joel") { do_anything }
6
+ # Glitch.inject! { execute code to glitch }
7
7
 
8
8
  module ChaoticJob
9
9
  class Glitch
10
+ def self.before_line(key, &block)
11
+ new.before_line(key, &block)
12
+ end
13
+
14
+ def self.before_call(key, ...)
15
+ new.before_call(key, ...)
16
+ end
17
+
18
+ def self.before_return(key, return_type = nil, &block)
19
+ new.before_return(key, return_type, &block)
20
+ end
21
+
10
22
  def initialize
11
23
  @breakpoints = {}
12
24
  end
@@ -26,6 +38,15 @@ module ChaoticJob
26
38
  self
27
39
  end
28
40
 
41
+ def set_action(force: false, &block)
42
+ @breakpoints.each do |_key, handlers|
43
+ handlers.each do |_event, handler|
44
+ handler[:block] = block if handler[:block].nil? || force
45
+ end
46
+ end
47
+ self
48
+ end
49
+
29
50
  def inject!(&block)
30
51
  breakpoints = @breakpoints
31
52
 
@@ -45,17 +66,11 @@ module ChaoticJob
45
66
  end
46
67
 
47
68
  def all_executed?
48
- @breakpoints.all? do |_location, handlers|
49
- handlers.all? { |_position, handler| handler[:executed] }
69
+ @breakpoints.all? do |_key, handlers|
70
+ handlers.all? { |_event, handler| handler[:executed] }
50
71
  end
51
72
  end
52
73
 
53
- # def inspect
54
- # @breakpoints.flat_map do |location, configs|
55
- # configs.keys.map { |position| "#{position}-#{location}" }
56
- # end.join("|>")
57
- # end
58
-
59
74
  private
60
75
 
61
76
  def set_breakpoint(key, event, *args, retval: nil, **kwargs, &block)
@@ -7,20 +7,20 @@ module ChaoticJob
7
7
  class Scenario
8
8
  attr_reader :events
9
9
 
10
- def initialize(job, glitches:, raise: RetryableError, capture: /active_job/)
10
+ def initialize(job, glitch:, raise: RetryableError, capture: /active_job/)
11
11
  @job = job
12
- @glitches = glitches
12
+ @glitch = (Glitch === glitch) ? glitch : (raise Error.new("glitch: must be a Glitch instance, but got #{glitch.inspect}"))
13
13
  @raise = binding.local_variable_get(:raise)
14
14
  @capture = capture
15
- @glitch = nil
16
15
  @events = []
17
16
  end
18
17
 
19
18
  def run(&block)
20
19
  @job.class.retry_on RetryableError, attempts: 10, wait: 1, jitter: 0
20
+ @glitch.set_action { raise @raise }
21
21
 
22
22
  ActiveSupport::Notifications.subscribed(->(event) { @events << event.dup }, @capture) do
23
- glitch.inject! do
23
+ @glitch.inject! do
24
24
  @job.enqueue
25
25
  if block
26
26
  block.call
@@ -30,25 +30,11 @@ module ChaoticJob
30
30
  end
31
31
  end
32
32
 
33
- # TODO: assert that all glitches ran
34
- end
35
-
36
- def to_s
37
- @glitches.map { |position, location| "#{position}-#{location}" }.join("|>")
33
+ # TODO: assert that all glitch ran
38
34
  end
39
35
 
40
36
  def all_glitched?
41
37
  @glitch.all_executed?
42
38
  end
43
-
44
- private
45
-
46
- def glitch
47
- @glitch ||= Glitch.new.tap do |glitch|
48
- @glitches.each do |kind, location, _description|
49
- glitch.public_send(kind, location) { raise @raise }
50
- end
51
- end
52
- end
53
39
  end
54
40
  end
@@ -6,13 +6,16 @@
6
6
  # Simulation.new(job).scenarios
7
7
  module ChaoticJob
8
8
  class Simulation
9
- def initialize(job, depth: 1, variations: 100, test: nil, seed: nil)
9
+ def initialize(job, callstack: nil, depth: 1, variations: 100, test: nil, seed: nil)
10
10
  @template = job
11
+ @callstack = callstack || capture_callstack
11
12
  @depth = depth
12
13
  @variations = variations
13
14
  @test = test
14
15
  @seed = seed || Random.new_seed
15
16
  @random = Random.new(@seed)
17
+
18
+ raise Error.new("callstack must be a generated via the ChaoticJob::Tracer") unless @callstack.is_a?(Stack)
16
19
  end
17
20
 
18
21
  def run(&callback)
@@ -27,9 +30,8 @@ module ChaoticJob
27
30
  end
28
31
 
29
32
  def permutations
30
- callstack = capture_callstack.map { |path, line| "#{path}:#{line}" }
31
- error_locations = callstack.map do |path, lineno|
32
- [:before_line, "#{path}:#{lineno}"]
33
+ error_locations = @callstack.map do |event, key|
34
+ ["before_#{event}", key]
33
35
  end
34
36
  error_locations.permutation(@depth)
35
37
  end
@@ -43,7 +45,8 @@ module ChaoticJob
43
45
  def scenarios
44
46
  variants.map do |glitches|
45
47
  job = clone_job_template
46
- scenario = Scenario.new(job, glitches: glitches)
48
+ glitch = Glitch.new.tap { |g| glitches.each { |event, key| g.public_send(event, key) } }
49
+ scenario = Scenario.new(job, glitch: glitch)
47
50
  job.job_id = scenario.to_s
48
51
  scenario
49
52
  end
@@ -52,23 +55,13 @@ module ChaoticJob
52
55
  private
53
56
 
54
57
  def capture_callstack
55
- return @callstack if defined?(@callstack)
56
-
57
- @callstack = Set.new
58
58
  job_class = @template.class
59
59
  job_file_path = job_class.instance_method(:perform).source_location&.first
60
+ tracer = Tracer.new { |tp| tp.path == job_file_path || tp.defined_class == job_class }
61
+ callstack = tracer.capture { @template.dup.perform_now }
60
62
 
61
- trace = TracePoint.new(:line) do |tp|
62
- next if tp.defined_class == self.class
63
- next unless tp.path == job_file_path ||
64
- tp.defined_class == job_class
65
-
66
- @callstack << [tp.path, tp.lineno]
67
- end
68
-
69
- trace.enable { @template.dup.perform_now }
70
63
  @template.class.queue_adapter.enqueued_jobs = []
71
- @callstack
64
+ callstack
72
65
  end
73
66
 
74
67
  def run_scenario(scenario, &callback)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tracer.new { |tp| tp.path.start_with? "foo" }
4
+ # Tracer.new.capture { code_execution_to_trace_callstack }
5
+
6
+ module ChaoticJob
7
+ class Tracer
8
+ def initialize(&constraint)
9
+ @constraint = constraint
10
+ @callstack = Stack.new
11
+ end
12
+
13
+ def capture(&block)
14
+ trace = TracePoint.new(:line, :call, :return) do |tp|
15
+ # :nocov: SimpleCov cannot track code executed _within_ a TracePoint
16
+ next if tp.defined_class == self.class
17
+ next unless @constraint.call(tp)
18
+
19
+ case tp.event
20
+ when :line
21
+ key = line_key(tp)
22
+ when :call, :return
23
+ key = call_key(tp)
24
+ end
25
+
26
+ @callstack << [tp.event, key]
27
+ # :nocov:
28
+ end
29
+
30
+ trace.enable(&block)
31
+ @callstack
32
+ end
33
+
34
+ private
35
+
36
+ # :nocov: SimpleCov cannot track code executed _within_ a TracePoint
37
+ def line_key(event)
38
+ "#{event.path}:#{event.lineno}"
39
+ end
40
+
41
+ def call_key(event)
42
+ if Module === event.self
43
+ "#{event.self}.#{event.method_id}"
44
+ else
45
+ "#{event.defined_class}##{event.method_id}"
46
+ end
47
+ end
48
+ # :nocov:
49
+ end
50
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChaoticJob
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/chaotic_job.rb CHANGED
@@ -3,13 +3,16 @@
3
3
  require_relative "chaotic_job/version"
4
4
  require_relative "chaotic_job/journal"
5
5
  require_relative "chaotic_job/performer"
6
+ require_relative "chaotic_job/tracer"
6
7
  require_relative "chaotic_job/glitch"
7
8
  require_relative "chaotic_job/scenario"
8
9
  require_relative "chaotic_job/simulation"
10
+ require "set"
9
11
 
10
12
  module ChaoticJob
11
- class RetryableError < StandardError
12
- end
13
+ Error = Class.new(StandardError)
14
+ RetryableError = Class.new(Error)
15
+ Stack = Set
13
16
 
14
17
  def self.log_to_journal!(item = nil, scope: nil)
15
18
  if item && scope
@@ -63,19 +66,23 @@ module ChaoticJob
63
66
  Performer.perform_all_after(time)
64
67
  end
65
68
 
66
- def run_simulation(job, depth: nil, variations: nil, &block)
69
+ def run_simulation(job, depth: nil, variations: nil, callstack: nil, &block)
67
70
  seed = defined?(RSpec) ? RSpec.configuration.seed : Minitest.seed
68
71
  kwargs = {test: self, seed: seed}
69
72
  kwargs[:depth] = depth if depth
70
73
  kwargs[:variations] = variations if variations
74
+ kwargs[:callstack] = callstack if callstack
71
75
  self.simulation_scenario = nil
72
76
  Simulation.new(job, **kwargs).run(&block)
73
77
  end
74
78
 
75
- def run_scenario(job, glitch: nil, glitches: nil, raise: nil, capture: nil, &block)
76
- kwargs = {glitches: glitches || [glitch]}
79
+ def run_scenario(job, glitch:, raise: nil, capture: nil, &block)
80
+ kwargs = {}
81
+
82
+ kwargs[:glitch] = glitch
77
83
  kwargs[:raise] = binding.local_variable_get(:raise) if binding.local_variable_get(:raise)
78
84
  kwargs[:capture] = capture if capture
85
+
79
86
  if block
80
87
  Scenario.new(job, **kwargs).run(&block)
81
88
  else
@@ -83,6 +90,8 @@ module ChaoticJob
83
90
  end
84
91
  end
85
92
 
93
+ private
94
+
86
95
  def assert(test, msg = nil)
87
96
  return super unless @simulation_scenario
88
97
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chaotic_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-27 00:00:00.000000000 Z
10
+ date: 2025-06-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activejob
@@ -41,6 +41,7 @@ files:
41
41
  - lib/chaotic_job/performer.rb
42
42
  - lib/chaotic_job/scenario.rb
43
43
  - lib/chaotic_job/simulation.rb
44
+ - lib/chaotic_job/tracer.rb
44
45
  - lib/chaotic_job/version.rb
45
46
  - sig/chaotic_job.rbs
46
47
  homepage: https://github.com/fractaledmind/chaotic_job