job_contracts 0.1.0 → 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +269 -5
- data/Rakefile +2 -0
- data/lib/job_contracts/concerns/contractable.rb +41 -27
- data/lib/job_contracts/concerns/sidekiq_contractable.rb +17 -24
- data/lib/job_contracts/contracts/contract.rb +41 -13
- data/lib/job_contracts/contracts/duration_contract.rb +5 -3
- data/lib/job_contracts/contracts/queue_name_contract.rb +10 -4
- data/lib/job_contracts/contracts/read_only_contract.rb +9 -5
- data/lib/job_contracts/railtie.rb +8 -0
- data/lib/job_contracts/sidekiq_job_hash_middleware.rb +10 -0
- data/lib/job_contracts/version.rb +3 -1
- data/lib/job_contracts.rb +2 -0
- metadata +66 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b3ed33558c769a0bc1ef0d74e1c066b5dd3de20069d6f0ea9a37177294080969
|
4
|
+
data.tar.gz: 8d89d059123f45dfcc3ce399cb51e13e23d6f6b3b6a7c7a93d06ffd2130f8de4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4a575f78c5fc94884dcc453bcc4931f12dbe30f904f5cc9915d69c291cf88f82b6ec84dd0ce85067cbd6647b8db4e232b8e3ee6f0201056dc01d137a7fdf1dc3
|
7
|
+
data.tar.gz: 43ea19008133a39f25661912029c23a42523b462f9cc882a819cd989c8f5ced4bcb7046a0d1657d8b29439647335e911974c7295bcf4fcc0daa5e16d3acd96aa
|
data/README.md
CHANGED
@@ -1,12 +1,276 @@
|
|
1
|
+
[![Lines of Code](http://img.shields.io/badge/lines_of_code-232-brightgreen.svg?style=flat)](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/)
|
2
|
+
[![Code Quality](https://app.codacy.com/project/badge/Grade/f604d4bc6db0474c802ef51182732488)](https://www.codacy.com/gh/hopsoft/job_contracts/dashboard?utm_source=github.com&utm_medium=referral&utm_content=hopsoft/job_contracts&utm_campaign=Badge_Grade)
|
3
|
+
![Tests](https://github.com/hopsoft/job_contracts/actions/workflows/test.yml/badge.svg)
|
4
|
+
[![Gem Version](https://badge.fury.io/rb/job_contracts.svg)](https://badge.fury.io/rb/job_contracts)
|
5
|
+
|
1
6
|
# Job Contracts
|
2
7
|
|
3
|
-
##
|
8
|
+
## Test-like assurances for jobs
|
9
|
+
|
10
|
+
Have you ever wanted to prevent a background job from writing to the database or perhaps ensure that it completes within a fixed amount of time?
|
11
|
+
|
12
|
+
Contracts allow you to easily enforce guarantees like this.
|
13
|
+
|
14
|
+
<!-- Tocer[start]: Auto-generated, don't remove. -->
|
15
|
+
|
16
|
+
## Table of Contents
|
17
|
+
|
18
|
+
- [Why use Contracts?](#why-use-contracts)
|
19
|
+
- [Quick Start](#quick-start)
|
20
|
+
- [Contracts](#contracts)
|
21
|
+
- [Breach of Contract](#breach-of-contract)
|
22
|
+
- [Anatomy of a Contract](#anatomy-of-a-contract)
|
23
|
+
- [Defining a Contract](#defining-a-contract)
|
24
|
+
- [Using a Contract](#using-a-contract)
|
25
|
+
- [Worker Formation/Topology](#worker-formationtopology)
|
26
|
+
- [Advanced Usage](#advanced-usage)
|
27
|
+
- [Sidekiq](#sidekiq)
|
28
|
+
- [Todo](#todo)
|
29
|
+
- [License](#license)
|
30
|
+
- [Sponsors](#sponsors)
|
31
|
+
|
32
|
+
<!-- Tocer[finish]: Auto-generated, don't remove. -->
|
33
|
+
|
34
|
+
## Why use Contracts?
|
35
|
+
|
36
|
+
- Organize your code for better reuse, consistency, and maintainability
|
37
|
+
- Refine your telemetry and instrumentation efforts
|
38
|
+
- Improve job performance via enforced *(SLAs/SLOs/SLIs)*
|
39
|
+
- Monitor and manage job queue backpressure
|
40
|
+
- Improve your worker formation/topology to support high throughput
|
41
|
+
|
42
|
+
## Quick Start
|
43
|
+
|
44
|
+
Imagine you want to ensure a specific job completes within 5 seconds of being enqueued.
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
class ImportantJob < ApplicationJob
|
48
|
+
include JobContracts::Contractable
|
49
|
+
|
50
|
+
queue_as :default
|
51
|
+
add_contract JobContracts::DurationContract.new(max: 5.seconds)
|
52
|
+
|
53
|
+
def perform
|
54
|
+
# logic...
|
55
|
+
end
|
56
|
+
|
57
|
+
# default callback that's invoked if the contract is breached
|
58
|
+
def contract_breached!(contract)
|
59
|
+
# handle breach...
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
*How to handle a [__breach of contract__](#breach-of-contract).*
|
65
|
+
|
66
|
+
## Contracts
|
67
|
+
|
68
|
+
A contract is an agreement that a job should fulfill.
|
69
|
+
Failing to satisfy the contract is considered a __breach of contract__.
|
70
|
+
|
71
|
+
Contracts help you track `actual` results and compare them to `expected` outcomes.
|
72
|
+
For example, this project has a default set of contracts that verify the following:
|
73
|
+
|
74
|
+
- That a job will [execute within a set amount of time](https://github.com/hopsoft/job_contracts/blob/main/lib/job_contracts/contracts/duration_contract.rb)
|
75
|
+
- That a job is only [performed on a specific queue](https://github.com/hopsoft/job_contracts/blob/main/lib/job_contracts/contracts/queue_name_contract.rb)
|
76
|
+
- That a job [does not write to the database](https://github.com/hopsoft/job_contracts/blob/main/lib/job_contracts/contracts/read_only_contract.rb)
|
77
|
+
|
78
|
+
### Breach of Contract
|
79
|
+
|
80
|
+
A __breach of contract__ is similar to a test failure; however, the breach can be handled in many different ways.
|
81
|
+
|
82
|
+
- Log and instrument the breach and continue
|
83
|
+
- Halt processing of the job and all other contracts and raise an exception
|
84
|
+
- Move the job to a queue where the contract will not be enforced
|
85
|
+
- etc...
|
86
|
+
|
87
|
+
*Mix and match any combination of these options to support your requirements.*
|
88
|
+
|
89
|
+
### Anatomy of a Contract
|
90
|
+
|
91
|
+
Contracts support the following constructor arguments.
|
92
|
+
|
93
|
+
- __`trigger`__ `[Symbol] (:before, *:after)` - when contract enforcement takes place, *before or after perform*
|
94
|
+
- __`halt`__ `[Boolean] (true, *false)` - indicates whether or not to stop processing when the contract is breached
|
95
|
+
- __`queues`__ `[Array<String,Symbol>]` - a list of queue names where this contract will be enforced _(defaults to the configured queue, or `*` if the queue has not beeen configured)_
|
96
|
+
- __`expected`__ `[Hash]` - a dictionary of contract expectations
|
97
|
+
|
98
|
+
### Defining a Contract
|
99
|
+
|
100
|
+
Here's a contrived, but simple, example that ensures the first argument passed to perform fits within a specific range of values.
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
# app/contracts/argument_contract.rb
|
104
|
+
class ArgumentContract < JobContracts::Contract
|
105
|
+
def initialize(range:)
|
106
|
+
# enforced on all queues
|
107
|
+
super queues: ["*"], expected: {range: range}
|
108
|
+
end
|
109
|
+
|
110
|
+
def enforce!(contractable)
|
111
|
+
actual[:argument] = contractable.arguments.first
|
112
|
+
self.satisfied = expected[:range].cover?(actual[:argument])
|
113
|
+
super
|
114
|
+
end
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
### Using a Contract
|
119
|
+
|
120
|
+
Here's how to use the `ArgumentContract` in a job.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
# app/jobs/argument_example_job.rb
|
124
|
+
class ArgumentExampleJob < ApplicationJob
|
125
|
+
include JobContracts::Contractable
|
126
|
+
|
127
|
+
queue_as :default
|
128
|
+
add_contract ArgumentContract.new(range: (1..10))
|
129
|
+
|
130
|
+
def perform(arg)
|
131
|
+
# logic...
|
132
|
+
end
|
133
|
+
|
134
|
+
# default callback that's invoked if the contract is breached
|
135
|
+
def contract_breached!(contract)
|
136
|
+
# handle breach...
|
137
|
+
end
|
138
|
+
end
|
139
|
+
```
|
4
140
|
|
5
|
-
|
141
|
+
This job will help ensure that the argument passed to perform is between 1 and 10.
|
142
|
+
*It's up to you to determine how to handle a breach of contract.*
|
6
143
|
|
7
|
-
|
8
|
-
|
144
|
+
## Worker Formation/Topology
|
145
|
+
|
146
|
+
Thoughtful Rails applications often use specialized worker formations.
|
147
|
+
|
148
|
+
A simple formation might be to use two sets of workers.
|
149
|
+
One set dedicated to fast low-latency jobs with plenty of dedicated compute resources *(CPUs, processes, threads, etc...)*,
|
150
|
+
with another set dedicated to slower jobs that uses fewer compute resources.
|
151
|
+
|
152
|
+
<img width="593" alt="Untitled 2 2022-04-29 15-06-13" src="https://user-images.githubusercontent.com/32920/166069103-e316dcc7-e601-43d0-90df-ad0eda20409b.png">
|
153
|
+
|
154
|
+
Say we determine that fast low-latency jobs should __not__ write to the database.
|
155
|
+
|
156
|
+
We can use a [`ReadOnlyContract`](https://github.com/hopsoft/job_contracts/blob/main/lib/job_contracts/contracts/read_only_contract.rb)
|
157
|
+
to enforce this decision. If the contract is breached, we can notify our apm/monitoring service and re-enqueue the job to a slower queue *(worker set)* where database writes are permitted.
|
158
|
+
This will ensure that our fast low-latency queue doesn't get clogged with slow-running jobs.
|
159
|
+
|
160
|
+
Here's an example job implementation that accomplishes this.
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
class FastJob < ApplicationJob
|
164
|
+
include JobContracts::Contractable
|
165
|
+
|
166
|
+
# Configure the queue before adding contracts
|
167
|
+
# It will be used as the default enforcement queue for contracts
|
168
|
+
queue_as :critical
|
169
|
+
|
170
|
+
# Only enforces on the critical queue
|
171
|
+
# This allows us to halt job execution and reenqueue the job to a different queue
|
172
|
+
# where the contract will not be enforced
|
173
|
+
#
|
174
|
+
# NOTE: the arg `queues: [:critical]` is default behavior in this example
|
175
|
+
# we're setting it explicitly here for illustration purposes
|
176
|
+
add_contract JobContracts::ReadOnlyContract.new(queues: [:critical])
|
177
|
+
|
178
|
+
def perform
|
179
|
+
# logic that shouldn't write to the database,
|
180
|
+
# but might accidentally due to complex or opaque internals
|
181
|
+
end
|
182
|
+
|
183
|
+
def contract_breached!(contract)
|
184
|
+
# log and notify apm/monitoring service
|
185
|
+
|
186
|
+
# re-enqueue to a different queue
|
187
|
+
# where the database write will be permitted
|
188
|
+
# i.e. where the contract will not be enforced
|
189
|
+
enqueue queue: :default
|
190
|
+
end
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
*Worker formations can be designed in countless ways to handle incredibly sophisticated requirements and operational constraints.
|
195
|
+
The only real limitation is your creativity.*
|
196
|
+
|
197
|
+
## Advanced Usage
|
198
|
+
|
199
|
+
It's possible to override the default callback method that handles contract breaches.
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
class ImportantJob < ApplicationJob
|
203
|
+
include JobContracts::Contractable
|
204
|
+
|
205
|
+
queue_as :default
|
206
|
+
on_contract_breach :take_action
|
207
|
+
add_contract JobContracts::DurationContract.new(max: 5.seconds)
|
208
|
+
|
209
|
+
def perform
|
210
|
+
# logic...
|
211
|
+
end
|
212
|
+
|
213
|
+
def take_action(contract)
|
214
|
+
# handle breach...
|
215
|
+
end
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
```ruby
|
220
|
+
class ImportantJob < ApplicationJob
|
221
|
+
include JobContracts::Contractable
|
222
|
+
|
223
|
+
queue_as :default
|
224
|
+
on_contract_breach -> (contract) { # take action... }
|
225
|
+
|
226
|
+
add_contract JobContracts::DurationContract.new(max: 5.seconds)
|
227
|
+
|
228
|
+
def perform
|
229
|
+
# logic...
|
230
|
+
end
|
231
|
+
end
|
232
|
+
```
|
233
|
+
|
234
|
+
## Sidekiq
|
235
|
+
|
236
|
+
`Sidekiq::Job`s are also supported.
|
237
|
+
|
238
|
+
```ruby
|
239
|
+
class ImportantJob
|
240
|
+
include Sidekiq::Job
|
241
|
+
include JobContracts::SidekiqContractable
|
242
|
+
|
243
|
+
sidekiq_options queue: :default
|
244
|
+
add_contract JobContracts::DurationContract.new(max: 1.second)
|
245
|
+
|
246
|
+
def perform
|
247
|
+
# logic...
|
248
|
+
end
|
249
|
+
|
250
|
+
# default callback that's invoked if the contract is breached
|
251
|
+
def contract_breached!(contract)
|
252
|
+
# handle breach...
|
253
|
+
end
|
254
|
+
end
|
255
|
+
```
|
256
|
+
|
257
|
+
## Todo
|
258
|
+
|
259
|
+
- [ ] Sidekiq tests
|
9
260
|
|
10
261
|
## License
|
11
262
|
|
12
|
-
The gem is available as open
|
263
|
+
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
264
|
+
|
265
|
+
## Sponsors
|
266
|
+
|
267
|
+
This project is sponsored by [Orbit.love](https://orbit.love/?utm_source=github&utm_medium=repo&utm_campaign=hopsoft&utm_content=job_contracts) *(mission control for your community)*.
|
268
|
+
|
269
|
+
<a href="https://orbit.love/?utm_source=github&utm_medium=repo&utm_campaign=hopsoft&utm_content=job_contracts">
|
270
|
+
<img height="50" src="https://user-images.githubusercontent.com/32920/166343064-55f92cdb-c81b-4f85-80a8-167bfda73c85.png"></img>
|
271
|
+
</a>
|
272
|
+
|
273
|
+
---
|
274
|
+
|
275
|
+
This effort was partly inspired by a presentation at [Sin City Ruby](https://www.sincityruby.com/) from our friends on the platform team at [Gusto](https://gusto.com/).
|
276
|
+
Their presentation validated some of my prior solutions aimed at accomplishing similar goals and motivated me to extract that work into a GEM.
|
data/Rakefile
CHANGED
@@ -1,3 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "monitor"
|
4
|
+
|
1
5
|
module JobContracts
|
2
6
|
# Universal mixin for jobs/workers
|
3
7
|
module Contractable
|
@@ -5,33 +9,43 @@ module JobContracts
|
|
5
9
|
|
6
10
|
module Prepends
|
7
11
|
extend ActiveSupport::Concern
|
12
|
+
include MonitorMixin
|
8
13
|
|
9
14
|
def perform(*args)
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
15
|
+
# fetch sidekiq job/worker metadata on main thread
|
16
|
+
try :sidekiq_job_metadata
|
17
|
+
|
18
|
+
halted = false
|
19
|
+
contracts.select(&:before?).each do |contract|
|
20
|
+
contract.enforce! self unless halted
|
21
|
+
halted = true if contract.breached? && contract.halt?
|
14
22
|
end
|
15
|
-
super
|
16
|
-
|
17
|
-
|
23
|
+
super unless halted
|
24
|
+
ensure
|
25
|
+
# enforce after contracts in a separate thread to ensure that any perform related behavior
|
26
|
+
# defined in ContractablePrepends will finish executing before we invoke contract.enforce!
|
27
|
+
Thread.new do
|
28
|
+
sleep 0
|
29
|
+
synchronize do
|
30
|
+
contracts.select(&:after?).each do |contract|
|
31
|
+
contract.enforce! self unless halted
|
32
|
+
end
|
33
|
+
end
|
18
34
|
end
|
19
35
|
end
|
20
36
|
end
|
21
37
|
|
22
38
|
module ClassMethods
|
23
|
-
|
24
|
-
|
25
|
-
def contracts_to_enforce_before_perform
|
26
|
-
@contracts_to_enforce_before_perform ||= Set.new
|
39
|
+
def contracts
|
40
|
+
@contracts ||= Set.new
|
27
41
|
end
|
28
42
|
|
29
|
-
def
|
30
|
-
@
|
43
|
+
def on_contract_breach(value = nil, &block)
|
44
|
+
@on_contract_breach_callback = value || block
|
31
45
|
end
|
32
46
|
|
33
|
-
def
|
34
|
-
@
|
47
|
+
def on_contract_breach_callback
|
48
|
+
@on_contract_breach_callback ||= :contract_breached!
|
35
49
|
end
|
36
50
|
|
37
51
|
def add_contract(contract)
|
@@ -45,21 +59,21 @@ module JobContracts
|
|
45
59
|
|
46
60
|
prepend JobContracts::Contractable::Prepends
|
47
61
|
|
48
|
-
if contract.
|
49
|
-
|
50
|
-
|
51
|
-
contracts_to_enforce_after_perform << contract
|
52
|
-
end
|
62
|
+
contract.queues << queue_name.to_s if contract.queues.blank? && queue_name.present?
|
63
|
+
contract.queues << "*" if contract.queues.blank?
|
64
|
+
contracts << contract
|
53
65
|
end
|
54
66
|
end
|
55
67
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
68
|
+
delegate :contracts, to: "self.class"
|
69
|
+
|
70
|
+
def breached_contracts
|
71
|
+
@breached_contracts ||= Set.new
|
72
|
+
end
|
73
|
+
|
74
|
+
# Default callback
|
75
|
+
def contract_breached!
|
76
|
+
# noop / override in job subclasses
|
63
77
|
end
|
64
78
|
end
|
65
79
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "contractable"
|
2
4
|
|
3
5
|
module JobContracts
|
@@ -6,41 +8,32 @@ module JobContracts
|
|
6
8
|
extend ActiveSupport::Concern
|
7
9
|
include Contractable
|
8
10
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
begin
|
14
|
-
attempts ||= 1
|
15
|
-
hit = Sidekiq::WorkSet.new.find do |_pid, _tid, work|
|
16
|
-
work.dig("payload", "jid") == jid
|
17
|
-
end
|
18
|
-
raise MetadataNotFoundError if hit.blank?
|
19
|
-
rescue MetadataNotFoundError
|
20
|
-
# The WorkSet only updates every 5 seconds
|
21
|
-
# SEE: https://github.com/mperham/sidekiq/wiki/API#workers
|
22
|
-
# Re-attempt up to 10 times with a simple backoff strategy (up to 5.5 seconds)
|
23
|
-
# TODO: Is there a faster and more reliable way to fetch the job's metadata after perform has begun?
|
24
|
-
# May need to query Redis directly if the data is still in there at this point
|
25
|
-
attempts += 1
|
26
|
-
if attempts <= 10
|
27
|
-
sleep 0.1 * attempts
|
28
|
-
retry
|
29
|
-
end
|
11
|
+
module ClassMethods
|
12
|
+
# Matches the ActiveJob API
|
13
|
+
def queue_name
|
14
|
+
sidekiq_options_hash["queue"]
|
30
15
|
end
|
16
|
+
end
|
31
17
|
|
32
|
-
|
18
|
+
# Metadata used to enqueue the job
|
19
|
+
def sidekiq_job_hash
|
20
|
+
@sidekiq_job_hash ||= {}
|
33
21
|
end
|
34
22
|
|
35
23
|
# Matches the ActiveJob API
|
36
24
|
def queue_name
|
37
|
-
|
25
|
+
sidekiq_job_hash["queue"]
|
38
26
|
end
|
39
27
|
|
40
28
|
# Matches the ActiveJob API
|
41
29
|
def enqueued_at
|
42
|
-
seconds =
|
30
|
+
seconds = sidekiq_job_hash["enqueued_at"]
|
43
31
|
(seconds ? Time.at(seconds) : nil)&.iso8601.to_s
|
44
32
|
end
|
33
|
+
|
34
|
+
# Matches the ActiveJob API
|
35
|
+
def arguments
|
36
|
+
sidekiq_job_hash["args"] || []
|
37
|
+
end
|
45
38
|
end
|
46
39
|
end
|
@@ -1,33 +1,36 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module JobContracts
|
4
4
|
class Contract
|
5
|
-
|
5
|
+
attr_reader :trigger, :queues
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
def initialize(trigger: :after, halt: false, **kwargs)
|
7
|
+
def initialize(trigger: :after, halt: false, queues: [], expected: {})
|
10
8
|
@trigger = trigger.to_sym
|
11
9
|
@halt = halt
|
12
|
-
|
10
|
+
@queues = Set.new(queues.map(&:to_s))
|
11
|
+
self.expected.merge! expected
|
13
12
|
end
|
14
13
|
|
15
|
-
def
|
16
|
-
@
|
14
|
+
def expected
|
15
|
+
@expected ||= HashWithIndifferentAccess.new
|
17
16
|
end
|
18
17
|
|
19
18
|
def actual
|
20
19
|
@actual ||= HashWithIndifferentAccess.new
|
21
20
|
end
|
22
21
|
|
22
|
+
def should_enforce?(contractable)
|
23
|
+
return true if queues.include?("*")
|
24
|
+
queues.include? contractable.queue_name.to_s
|
25
|
+
end
|
26
|
+
|
23
27
|
# Method to be implemented by subclasses
|
24
28
|
# NOTE: subclasses should update `actual`, set `satisfied`, and call `super`
|
25
29
|
def enforce!(contractable)
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
delete_observer contractable
|
30
|
+
return unless should_enforce?(contractable)
|
31
|
+
return if satisfied?
|
32
|
+
contractable.breached_contracts << self
|
33
|
+
invoke_contract_breach_callback contractable
|
31
34
|
end
|
32
35
|
|
33
36
|
def satisfied?
|
@@ -42,8 +45,33 @@ module JobContracts
|
|
42
45
|
!!@halt
|
43
46
|
end
|
44
47
|
|
48
|
+
def before?
|
49
|
+
trigger == :before
|
50
|
+
end
|
51
|
+
|
52
|
+
def after?
|
53
|
+
trigger == :after
|
54
|
+
end
|
55
|
+
|
56
|
+
def to_h
|
57
|
+
HashWithIndifferentAccess.new(
|
58
|
+
name: self.class.name,
|
59
|
+
trigger: trigger,
|
60
|
+
halt: halt?,
|
61
|
+
queues: queues.to_a,
|
62
|
+
expected: expected,
|
63
|
+
actual: actual
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
45
67
|
protected
|
46
68
|
|
47
69
|
attr_accessor :satisfied
|
70
|
+
|
71
|
+
def invoke_contract_breach_callback(contractable)
|
72
|
+
callback = contractable.class.on_contract_breach_callback
|
73
|
+
callback = contractable.method(callback.to_sym) unless callback.is_a?(Proc)
|
74
|
+
callback.call self
|
75
|
+
end
|
48
76
|
end
|
49
77
|
end
|
@@ -1,14 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "contract"
|
2
4
|
|
3
5
|
module JobContracts
|
4
6
|
class DurationContract < Contract
|
5
|
-
def initialize(
|
6
|
-
super
|
7
|
+
def initialize(max:, queues: ["*"])
|
8
|
+
super queues: queues, expected: {max: max}
|
7
9
|
end
|
8
10
|
|
9
11
|
def enforce!(contractable)
|
10
12
|
actual[:duration] = (Time.current - Time.parse(contractable.enqueued_at)).seconds
|
11
|
-
self.satisfied = actual[:duration] <
|
13
|
+
self.satisfied = actual[:duration] < expected[:max].seconds
|
12
14
|
super
|
13
15
|
end
|
14
16
|
end
|
@@ -1,15 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "contract"
|
2
4
|
|
3
5
|
module JobContracts
|
4
6
|
class QueueNameContract < Contract
|
5
7
|
def initialize(queue_name:)
|
6
|
-
super
|
8
|
+
super(
|
9
|
+
trigger: :before,
|
10
|
+
halt: true,
|
11
|
+
queues: ["*"],
|
12
|
+
expected: {queue_name: queue_name.to_s}
|
13
|
+
)
|
7
14
|
end
|
8
15
|
|
9
16
|
def enforce!(contractable)
|
10
|
-
queue_name = contractable.queue_name
|
11
|
-
|
12
|
-
self.satisfied = queue_name.to_s == expect[:queue_name].to_s
|
17
|
+
actual[:queue_name] = contractable.queue_name.to_s
|
18
|
+
self.satisfied = contractable.queue_name.to_s == expected[:queue_name]
|
13
19
|
super
|
14
20
|
end
|
15
21
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative "contract"
|
2
4
|
|
3
5
|
module JobContracts
|
@@ -6,7 +8,12 @@ module JobContracts
|
|
6
8
|
extend ActiveSupport::Concern
|
7
9
|
|
8
10
|
def perform(*args)
|
9
|
-
|
11
|
+
contract = contracts.find { |c| c.is_a?(ReadOnlyContract) }
|
12
|
+
if contract.should_enforce?(self)
|
13
|
+
ActiveRecord::Base.while_preventing_writes do
|
14
|
+
super
|
15
|
+
end
|
16
|
+
else
|
10
17
|
super
|
11
18
|
end
|
12
19
|
rescue ActiveRecord::ReadOnlyError => error
|
@@ -21,11 +28,8 @@ module JobContracts
|
|
21
28
|
end
|
22
29
|
end
|
23
30
|
|
24
|
-
# def initialize
|
25
|
-
# super trigger: :before
|
26
|
-
# end
|
27
|
-
|
28
31
|
def enforce!(contractable)
|
32
|
+
self.satisfied = true
|
29
33
|
if contractable.read_only_error.present?
|
30
34
|
actual[:error] = contractable.read_only_error.message
|
31
35
|
self.satisfied = false
|
@@ -1,4 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq"
|
4
|
+
require_relative "sidekiq_job_hash_middleware"
|
5
|
+
|
1
6
|
module JobContracts
|
2
7
|
class Railtie < ::Rails::Railtie
|
8
|
+
initializer "job_contracts.register_sidekiq_middleware" do
|
9
|
+
Sidekiq.server_middleware.add SidekiqJobHashMiddleware
|
10
|
+
end
|
3
11
|
end
|
4
12
|
end
|
data/lib/job_contracts.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: job_contracts
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathan Hopkins
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-04
|
11
|
+
date: 2022-05-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -42,17 +42,73 @@ dependencies:
|
|
42
42
|
name: standard
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
|
-
- - "
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: magic_frozen_string_literal
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry-rails
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
46
74
|
- !ruby/object:Gem::Version
|
47
|
-
version:
|
75
|
+
version: '0'
|
48
76
|
type: :development
|
49
77
|
prerelease: false
|
50
78
|
version_requirements: !ruby/object:Gem::Requirement
|
51
79
|
requirements:
|
52
|
-
- - "
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry-doc
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: tocer
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
53
109
|
- !ruby/object:Gem::Version
|
54
|
-
version:
|
55
|
-
description: Enforceable contracts for
|
110
|
+
version: '0'
|
111
|
+
description: Enforceable contracts for jobs
|
56
112
|
email:
|
57
113
|
- natehop@gmail.com
|
58
114
|
executables: []
|
@@ -70,6 +126,7 @@ files:
|
|
70
126
|
- lib/job_contracts/contracts/queue_name_contract.rb
|
71
127
|
- lib/job_contracts/contracts/read_only_contract.rb
|
72
128
|
- lib/job_contracts/railtie.rb
|
129
|
+
- lib/job_contracts/sidekiq_job_hash_middleware.rb
|
73
130
|
- lib/job_contracts/version.rb
|
74
131
|
homepage: https://github.com/hopsoft/job_contracts
|
75
132
|
licenses:
|
@@ -93,8 +150,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
150
|
- !ruby/object:Gem::Version
|
94
151
|
version: '0'
|
95
152
|
requirements: []
|
96
|
-
rubygems_version: 3.3.
|
153
|
+
rubygems_version: 3.3.3
|
97
154
|
signing_key:
|
98
155
|
specification_version: 4
|
99
|
-
summary: Enforceable contracts for
|
156
|
+
summary: Enforceable contracts for jobs
|
100
157
|
test_files: []
|