job_contracts 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +258 -5
- data/Rakefile +2 -0
- data/lib/job_contracts/concerns/contractable.rb +47 -27
- data/lib/job_contracts/concerns/sidekiq_contractable.rb +38 -23
- data/lib/job_contracts/contracts/contract.rb +34 -6
- 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 +2 -0
- data/lib/job_contracts/version.rb +3 -1
- data/lib/job_contracts.rb +2 -0
- metadata +61 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3409ea614e4698241974af1291cac6b6e2fc0cf75c45af5d7d5ea61cde545998
|
4
|
+
data.tar.gz: 7a59f4f8408f9d0c7963578b411bc5425da56e7138cb35e7cd0ee938f342dc58
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6e3329c2fb4ff9a4a898c44453504259b211389f8f2d57a6a6ba1a0722da414ad843030e8c1991e79ba28dfe71abedad9ef016f130ba469a5dc37c23c441572a
|
7
|
+
data.tar.gz: 40be8734e8791531b7b03fde19f89697a10744ec0f77e688a091090df9a998bffd8aa3d70c95bd1e31aba024f10338a4aa7fdea712cef9f8eeaefb4beb2fbc45
|
data/README.md
CHANGED
@@ -1,12 +1,265 @@
|
|
1
|
+
[![Lines of Code](http://img.shields.io/badge/lines_of_code-240-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
|
+
|
1
5
|
# Job Contracts
|
2
6
|
|
3
|
-
##
|
7
|
+
## Test-like assurances for jobs
|
8
|
+
|
9
|
+
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?
|
10
|
+
|
11
|
+
Contracts allow you to easily enforce guarantees like this.
|
12
|
+
|
13
|
+
<!-- Tocer[start]: Auto-generated, don't remove. -->
|
14
|
+
|
15
|
+
## Table of Contents
|
16
|
+
|
17
|
+
- [Why use Contracts?](#why-use-contracts)
|
18
|
+
- [Quick Start](#quick-start)
|
19
|
+
- [Contracts](#contracts)
|
20
|
+
- [Breach of Contract](#breach-of-contract)
|
21
|
+
- [Anatomy of a Contract](#anatomy-of-a-contract)
|
22
|
+
- [Defining a Contract](#defining-a-contract)
|
23
|
+
- [Using a Contract](#using-a-contract)
|
24
|
+
- [Worker Formation/Topology](#worker-formationtopology)
|
25
|
+
- [Advanced Usage](#advanced-usage)
|
26
|
+
- [Sidekiq](#sidekiq)
|
27
|
+
- [Todo](#todo)
|
28
|
+
- [License](#license)
|
29
|
+
- [Sponsors](#sponsors)
|
30
|
+
|
31
|
+
<!-- Tocer[finish]: Auto-generated, don't remove. -->
|
32
|
+
|
33
|
+
## Why use Contracts?
|
34
|
+
|
35
|
+
- Organize your code for better reuse, consistency, and maintainability
|
36
|
+
- Refine your telemetry and instrumentation efforts
|
37
|
+
- Improve job performance via enforced *(SLAs/SLOs/SLIs)*
|
38
|
+
- Monitor and manage job queue backpressure
|
39
|
+
- Improve your worker formation/topology to support high throughput
|
40
|
+
|
41
|
+
## Quick Start
|
42
|
+
|
43
|
+
Imagine you want to ensure a specific job completes within 5 seconds of being enqueued.
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
class ImportantJob < ApplicationJob
|
47
|
+
include JobContracts::Contractable
|
48
|
+
|
49
|
+
queue_as :default
|
50
|
+
|
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 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))
|
4
129
|
|
5
|
-
|
130
|
+
def perform(arg)
|
131
|
+
# logic...
|
132
|
+
end
|
6
133
|
|
7
|
-
|
8
|
-
|
134
|
+
# default callback that's invoked if the contract is breached
|
135
|
+
def contract_breached!(contract)
|
136
|
+
# handle breach...
|
137
|
+
end
|
138
|
+
end
|
139
|
+
```
|
140
|
+
|
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.*
|
143
|
+
|
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
|
+
|
207
|
+
on_contract_breach :take_action
|
208
|
+
add_contract JobContracts::DurationContract.new(max: 5.seconds)
|
209
|
+
|
210
|
+
def perform
|
211
|
+
# logic...
|
212
|
+
end
|
213
|
+
|
214
|
+
def take_action(contract)
|
215
|
+
# handle breach...
|
216
|
+
end
|
217
|
+
end
|
218
|
+
```
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
class ImportantJob < ApplicationJob
|
222
|
+
include JobContracts::Contractable
|
223
|
+
|
224
|
+
queue_as :default
|
225
|
+
|
226
|
+
on_contract_breach -> (contract) {
|
227
|
+
# take action...
|
228
|
+
}
|
229
|
+
|
230
|
+
add_contract JobContracts::DurationContract.new(max: 5.seconds)
|
231
|
+
|
232
|
+
def perform
|
233
|
+
# logic...
|
234
|
+
end
|
235
|
+
end
|
236
|
+
```
|
237
|
+
|
238
|
+
## Sidekiq
|
239
|
+
|
240
|
+
Sidekiq jobs/workers are supported.
|
241
|
+
Unfortunately this support comes with a performance penalty *(i.e. additional latency)* because executing
|
242
|
+
Sidekiq jobs don't have access to their own metadata. To get around this, we wait to find job metadata in the active
|
243
|
+
[`WorkSet`](https://github.com/hopsoft/job_contracts/blob/main/lib/job_contracts/concerns/sidekiq_contractable.rb#L23-L25)
|
244
|
+
which is only updated [every 5 seconds](https://github.com/mperham/sidekiq/wiki/API#workers).
|
245
|
+
|
246
|
+
## Todo
|
247
|
+
|
248
|
+
- [ ] Sidekiq tests
|
9
249
|
|
10
250
|
## License
|
11
251
|
|
12
|
-
The gem is available as open
|
252
|
+
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
253
|
+
|
254
|
+
## Sponsors
|
255
|
+
|
256
|
+
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)*.
|
257
|
+
|
258
|
+
<a href="https://orbit.love/?utm_source=github&utm_medium=repo&utm_campaign=hopsoft&utm_content=job_contracts">
|
259
|
+
<img height="50" src="https://user-images.githubusercontent.com/32920/166343064-55f92cdb-c81b-4f85-80a8-167bfda73c85.png"></img>
|
260
|
+
</a>
|
261
|
+
|
262
|
+
---
|
263
|
+
|
264
|
+
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/).
|
265
|
+
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,27 @@ 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
|
77
|
+
end
|
78
|
+
|
79
|
+
def on_contract_breach(contract)
|
80
|
+
breached_contracts << contract
|
81
|
+
method = self.class.on_contract_breach_callback
|
82
|
+
method.is_a?(Proc) ? method.call(contract) : send(method.to_s, contract)
|
63
83
|
end
|
64
84
|
end
|
65
85
|
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,54 @@ module JobContracts
|
|
6
8
|
extend ActiveSupport::Concern
|
7
9
|
include Contractable
|
8
10
|
|
9
|
-
class
|
11
|
+
class SidekiqJobMetadataNotFoundError < StandardError; end
|
10
12
|
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
13
|
+
module ClassMethods
|
14
|
+
def queue_name
|
15
|
+
sidekiq_options_hash["queue"]
|
30
16
|
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def sidekiq_job_metadata
|
20
|
+
@sidekiq_job_metadata ||= begin
|
21
|
+
hit = nil
|
22
|
+
begin
|
23
|
+
attempts ||= 1
|
24
|
+
hit = Sidekiq::Workers.new.find do |_process_id, _thread_id, work|
|
25
|
+
work.dig("payload", "jid") == jid
|
26
|
+
end
|
27
|
+
raise SidekiqJobMetadataNotFoundError if hit.blank?
|
28
|
+
rescue SidekiqJobMetadataNotFoundError
|
29
|
+
# The WorkSet only updates every 5 seconds
|
30
|
+
# SEE: https://github.com/mperham/sidekiq/wiki/API#workers
|
31
|
+
# Re-attempt up to 10 times with a simple backoff strategy (up to 5.5 seconds)
|
32
|
+
# TODO: Is there a faster and more reliable way to fetch the job's metadata after perform has begun?
|
33
|
+
# May need to query Redis directly if the data is still in there at this point
|
34
|
+
attempts += 1
|
35
|
+
if attempts <= 10
|
36
|
+
sleep 0.1 * attempts
|
37
|
+
retry
|
38
|
+
end
|
39
|
+
end
|
31
40
|
|
32
|
-
|
41
|
+
hit&.last || {}
|
42
|
+
end
|
33
43
|
end
|
34
44
|
|
35
45
|
# Matches the ActiveJob API
|
36
46
|
def queue_name
|
37
|
-
|
47
|
+
sidekiq_job_metadata["queue"]
|
38
48
|
end
|
39
49
|
|
40
50
|
# Matches the ActiveJob API
|
41
51
|
def enqueued_at
|
42
|
-
seconds =
|
52
|
+
seconds = sidekiq_job_metadata.dig("payload", "enqueued_at")
|
43
53
|
(seconds ? Time.at(seconds) : nil)&.iso8601.to_s
|
44
54
|
end
|
55
|
+
|
56
|
+
# Matches the ActiveJob API
|
57
|
+
def arguments
|
58
|
+
sidekiq_job_metadata.dig("payload", "args") || []
|
59
|
+
end
|
45
60
|
end
|
46
61
|
end
|
@@ -1,29 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "observer"
|
2
4
|
|
3
5
|
module JobContracts
|
4
6
|
class Contract
|
5
7
|
include Observable
|
6
8
|
|
7
|
-
attr_reader :trigger
|
9
|
+
attr_reader :trigger, :queues
|
8
10
|
|
9
|
-
def initialize(trigger: :after, halt: false,
|
11
|
+
def initialize(trigger: :after, halt: false, queues: [], expected: {})
|
10
12
|
@trigger = trigger.to_sym
|
11
13
|
@halt = halt
|
12
|
-
|
14
|
+
@queues = Set.new(queues.map(&:to_s))
|
15
|
+
self.expected.merge! expected
|
13
16
|
end
|
14
17
|
|
15
|
-
def
|
16
|
-
@
|
18
|
+
def expected
|
19
|
+
@expected ||= HashWithIndifferentAccess.new
|
17
20
|
end
|
18
21
|
|
19
22
|
def actual
|
20
23
|
@actual ||= HashWithIndifferentAccess.new
|
21
24
|
end
|
22
25
|
|
26
|
+
def should_enforce?(contractable)
|
27
|
+
return true if queues.include?("*")
|
28
|
+
queues.include? contractable.queue_name.to_s
|
29
|
+
end
|
30
|
+
|
23
31
|
# Method to be implemented by subclasses
|
24
32
|
# NOTE: subclasses should update `actual`, set `satisfied`, and call `super`
|
25
33
|
def enforce!(contractable)
|
26
|
-
|
34
|
+
return unless should_enforce?(contractable)
|
35
|
+
add_observer contractable, :on_contract_breach
|
27
36
|
changed if breached?
|
28
37
|
notify_observers self
|
29
38
|
ensure
|
@@ -42,6 +51,25 @@ module JobContracts
|
|
42
51
|
!!@halt
|
43
52
|
end
|
44
53
|
|
54
|
+
def before?
|
55
|
+
trigger == :before
|
56
|
+
end
|
57
|
+
|
58
|
+
def after?
|
59
|
+
trigger == :after
|
60
|
+
end
|
61
|
+
|
62
|
+
def to_h
|
63
|
+
HashWithIndifferentAccess.new(
|
64
|
+
name: self.class.name,
|
65
|
+
trigger: trigger,
|
66
|
+
halt: halt?,
|
67
|
+
queues: queues.to_a,
|
68
|
+
expected: expected,
|
69
|
+
actual: actual
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
45
73
|
protected
|
46
74
|
|
47
75
|
attr_accessor :satisfied
|
@@ -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
|
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.1
|
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-
|
11
|
+
date: 2022-05-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -52,7 +52,63 @@ dependencies:
|
|
52
52
|
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: 1.10.0
|
55
|
-
|
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
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
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
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
description: Enforceable contracts for jobs
|
56
112
|
email:
|
57
113
|
- natehop@gmail.com
|
58
114
|
executables: []
|
@@ -93,8 +149,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
149
|
- !ruby/object:Gem::Version
|
94
150
|
version: '0'
|
95
151
|
requirements: []
|
96
|
-
rubygems_version: 3.3.
|
152
|
+
rubygems_version: 3.3.3
|
97
153
|
signing_key:
|
98
154
|
specification_version: 4
|
99
|
-
summary: Enforceable contracts for
|
155
|
+
summary: Enforceable contracts for jobs
|
100
156
|
test_files: []
|