job_contracts 0.1.0 → 0.1.3
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 +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
|
+
[](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/)
|
|
2
|
+
[](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
|
+

|
|
4
|
+
[](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: []
|