cutoff 0.1.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 +7 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +373 -0
- data/lib/cutoff.rb +166 -0
- data/lib/cutoff/error.rb +31 -0
- data/lib/cutoff/patch.rb +7 -0
- data/lib/cutoff/patch/mysql2.rb +169 -0
- data/lib/cutoff/version.rb +8 -0
- metadata +116 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e27bb02cc23dc725a4bcd3b9585159723e3fa4d3c88f98ec207ff2b91cf438d4
|
4
|
+
data.tar.gz: 8ab5a3a462bbbb2a32837e69de70d55ac59b3e347ead1db889d374f2be5b9224
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c957634f0bc03f9fa8fb058080338978322adb54fce2c2c952c7b684ea3a979165f076abdc29878e416a084acd7b904ad6e8d637f0c1b90091ffa179fdb6076d
|
7
|
+
data.tar.gz: b9ac92d37812b341fba24c6a3feb5c0f1f4c9265f1995aa5266b85e3d46e27c48e80fd4801ecc4f46d9a22b5195e0b1dfc35bc9c74449e2ac35c11ace28a5f15
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
## [0.1.0] - 2021-07-19
|
11
|
+
|
12
|
+
### Added
|
13
|
+
|
14
|
+
- Cutoff class
|
15
|
+
- Mysql2 patch
|
16
|
+
|
17
|
+
[0.1.0]: https://github.com/justinhoward/cutoff/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Justin Howard
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,373 @@
|
|
1
|
+
Cutoff
|
2
|
+
==================
|
3
|
+
|
4
|
+
[](https://badge.fury.io/rb/cutoff)
|
5
|
+
[](https://github.com/justinhoward/cutoff/actions?query=workflow%3ACI+branch%3Amaster)
|
6
|
+
[](https://www.codacy.com/gh/justinhoward/cutoff/dashboard?utm_source=github.com&utm_medium=referral&utm_content=justinhoward/cutoff&utm_campaign=Badge_Grade)
|
7
|
+
[](https://www.codacy.com/gh/justinhoward/cutoff/dashboard?utm_source=github.com&utm_medium=referral&utm_content=justinhoward/cutoff&utm_campaign=Badge_Coverage)
|
8
|
+
[](http://inch-ci.org/github/justinhoward/cutoff)
|
9
|
+
|
10
|
+
A deadlines library for Ruby inspired by Shopify and
|
11
|
+
[Kir Shatrov's blog series][kir shatrov].
|
12
|
+
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
Cutoff.wrap(5) do
|
16
|
+
sleep(4)
|
17
|
+
Cutoff.checkpoint! # still have time left
|
18
|
+
sleep(2)
|
19
|
+
Cutoff.checkpoint! # raises an error
|
20
|
+
end
|
21
|
+
```
|
22
|
+
|
23
|
+
It has a built-in patch for Mysql2 to auto-insert checkpoints and timeout query
|
24
|
+
hints.
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
require 'cutoff/patch/mysql2'
|
28
|
+
|
29
|
+
client = Mysql2::Client.new
|
30
|
+
Cutoff.wrap(5) do
|
31
|
+
client.query('SELECT * FROM dual WHERE sleep(2)')
|
32
|
+
|
33
|
+
# Cutoff will automatically insert a /*+ MAX_EXECUTION_TIME(3000) */
|
34
|
+
# hint so that MySQL will terminate the query after the time remaining
|
35
|
+
#
|
36
|
+
# Or if time already expired, this will raise an error and not be executed
|
37
|
+
client.query('SELECT * FROM dual WHERE sleep(1)')
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
Why use deadlines?
|
42
|
+
------------------------
|
43
|
+
|
44
|
+
If you've already implemented timeouts for your networked dependencies, then you
|
45
|
+
can be sure that no single HTTP request or database query can take longer than
|
46
|
+
the time allotted to it.
|
47
|
+
|
48
|
+
For example, let's say you set a query timeout of 3 seconds. That means no
|
49
|
+
single query will take longer than 3 seconds. However, imagine a bad controller
|
50
|
+
action or background job executes 100 slow queries. In that case, the queries
|
51
|
+
add up to 300 seconds, much too long.
|
52
|
+
|
53
|
+
Deadlines keep track of the total elapsed time in a request of job and interrupt
|
54
|
+
it if it takes too long.
|
55
|
+
|
56
|
+
Installation
|
57
|
+
---------------
|
58
|
+
|
59
|
+
Add it to your `Gemfile`:
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
gem 'cutoff'
|
63
|
+
```
|
64
|
+
|
65
|
+
Or install it manually:
|
66
|
+
|
67
|
+
```sh
|
68
|
+
gem install cutoff
|
69
|
+
```
|
70
|
+
|
71
|
+
API Documentation
|
72
|
+
------------------
|
73
|
+
|
74
|
+
API docs can be read [on rubydoc.info][api docs], inline in the source code, or
|
75
|
+
you can generate them yourself with Ruby `yard`:
|
76
|
+
|
77
|
+
```sh
|
78
|
+
bin/yardoc
|
79
|
+
```
|
80
|
+
|
81
|
+
Then open `doc/index.html` in your browser.
|
82
|
+
|
83
|
+
Usage
|
84
|
+
-----------
|
85
|
+
|
86
|
+
The simplest way to use Cutoff is to use its class methods, although it can be
|
87
|
+
used in an object-oriented manner as well.
|
88
|
+
|
89
|
+
### Wrapping a block
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
Cutoff.wrap(3.5) do # number of allowed seconds for this block
|
93
|
+
# Do something time-consuming here
|
94
|
+
|
95
|
+
# At a good stopping point, call checkpoint!
|
96
|
+
# If the allowed time is exceeded, this raises a Cutoff::CutoffExceededError
|
97
|
+
# otherwise, it does nothing
|
98
|
+
Cutoff.checkpoint!
|
99
|
+
|
100
|
+
# Now continue executing
|
101
|
+
end
|
102
|
+
```
|
103
|
+
|
104
|
+
### Creating your own instance
|
105
|
+
|
106
|
+
```ruby
|
107
|
+
cutoff = Cutoff.new(6.4)
|
108
|
+
sleep(10)
|
109
|
+
cutoff.checkpoint! # Raises Cutoff::CutoffExceededError
|
110
|
+
```
|
111
|
+
|
112
|
+
### Getting cutoff details
|
113
|
+
|
114
|
+
Cutoff has some instance methods to get information about the time remaining,
|
115
|
+
etc.
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
# If you're using Cutoff class methods, you can get the current instance
|
119
|
+
cutoff = Cutoff.current # careful, this will be nil if a cutoff isn't running
|
120
|
+
```
|
121
|
+
|
122
|
+
Once you have an instance, either by creating your own or from `.current`, you
|
123
|
+
have access to these methods.
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
cutoff = Cutoff.current
|
127
|
+
|
128
|
+
# These return Floats
|
129
|
+
cutoff.allowed_seconds # Total seconds allowed (the seconds given when cutoff was started)
|
130
|
+
cutoff.seconds_remaining # Seconds left
|
131
|
+
cutoff.elapsed_seconds # Seconds since the cutoff was started
|
132
|
+
cutoff.ms_remaining # Milliseconds left
|
133
|
+
|
134
|
+
cutoff.exceeded? # True if the cutoff is expired
|
135
|
+
```
|
136
|
+
|
137
|
+
Patches
|
138
|
+
-------------
|
139
|
+
|
140
|
+
Cutoff is in early stages, but it aims to provide patches for common networked
|
141
|
+
dependencies. The first of these is the `mysql2` patch. It is not loaded by
|
142
|
+
default, so you need to require it manually.
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
# In your Gemfile
|
146
|
+
gem 'cutoff', require: %w[cutoff cutoff/patch/mysql2]
|
147
|
+
```
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
# Or manually
|
151
|
+
require 'cutoff'
|
152
|
+
require 'cutoff/patch/mysql2'
|
153
|
+
```
|
154
|
+
|
155
|
+
Once it is enabled, any `Mysql2::Client` object will respect the current cutoff
|
156
|
+
if one is set.
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
client = Mysql2::Client.new
|
160
|
+
Cutoff.wrap(3) do
|
161
|
+
sleep(4)
|
162
|
+
|
163
|
+
# This query will not be executed because the time is already expired
|
164
|
+
client.query('SELECT * FROM users')
|
165
|
+
end
|
166
|
+
|
167
|
+
Cutoff.wrap(3) do
|
168
|
+
sleep(1)
|
169
|
+
|
170
|
+
# There are 2 seconds left, so a MAX_EXECUTION_TIME query hint is added
|
171
|
+
# to inform MySQL we only have 2 seconds to execute this query
|
172
|
+
# The executed query will be "SELECT /*+ MAX_EXECUTION_TIME(2000) */ * FROM users"
|
173
|
+
client.query('SELECT * FROM users')
|
174
|
+
|
175
|
+
# MySQL only supports MAX_EXECUTION_TIME for SELECTs so no query hint here
|
176
|
+
client.query("INSERT INTO users(first_name) VALUES('Joe')")
|
177
|
+
|
178
|
+
sleep(3)
|
179
|
+
|
180
|
+
# We don't even execute this query because time is already expired
|
181
|
+
# This limit applies to all queries, including INSERTS, etc
|
182
|
+
client.query('SELECT * FROM users')
|
183
|
+
end
|
184
|
+
```
|
185
|
+
|
186
|
+
Timing a Rails Controller
|
187
|
+
---------------------------
|
188
|
+
|
189
|
+
One use of a cutoff is to add a deadline to a Rails controller action.
|
190
|
+
|
191
|
+
```ruby
|
192
|
+
around_action { |_controller, action| Cutoff.wrap(2.5) { action.call } }
|
193
|
+
```
|
194
|
+
|
195
|
+
Now in your action, you can call `checkpoint!`, or if you're using the Mysql2
|
196
|
+
patch, checkpoints will be added automatically.
|
197
|
+
|
198
|
+
```ruby
|
199
|
+
def index
|
200
|
+
# Do thing one
|
201
|
+
Cutoff.checkpoint!
|
202
|
+
|
203
|
+
# Do something else
|
204
|
+
end
|
205
|
+
```
|
206
|
+
|
207
|
+
Consider adding a global error handler for the `Cutoff::CutoffExceededError`
|
208
|
+
|
209
|
+
```ruby
|
210
|
+
class ApplicationController < ActionController::Base
|
211
|
+
rescue_from Cutoff::CutoffExceededError, with: :handle_cutoff_exceeded
|
212
|
+
|
213
|
+
def handle_cutoff_exceeded
|
214
|
+
# Render a nice error page
|
215
|
+
end
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
Multi-threading
|
220
|
+
-----------------
|
221
|
+
|
222
|
+
In multi-threaded environments, cutoff class methods are independent in each
|
223
|
+
thread. That means that if you start a cutoff in one thread then start a new
|
224
|
+
thread, the second thread _will not_ inherit the cutoff from its parent thread.
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
Cutoff.wrap(6) do
|
228
|
+
Thread.new do
|
229
|
+
# This code can run as long as it wants because the class-level
|
230
|
+
# cutoff is independent
|
231
|
+
|
232
|
+
Cutoff.wrap(3) do
|
233
|
+
# However, you can start a new cutoff inside the new thread and it
|
234
|
+
# will not affect any other threads
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
```
|
239
|
+
|
240
|
+
The same rules apply to fibers. Each fiber has independent class-level cutoff
|
241
|
+
instances. This means you can use Cutoff in a multi-threaded web server or job
|
242
|
+
runner without worrying about thread conflicts.
|
243
|
+
|
244
|
+
If you want to use a single cutoff for multi-threading, you'll need to pass an
|
245
|
+
instance of a Cutoff.
|
246
|
+
|
247
|
+
```ruby
|
248
|
+
cutoff = Cutoff.new(6)
|
249
|
+
cutoff.checkpoint! # parent thread can call checkpoint!
|
250
|
+
Thread.new do
|
251
|
+
# And the child thread can use the same cutoff
|
252
|
+
cutoff.checkpoint!
|
253
|
+
end
|
254
|
+
end
|
255
|
+
```
|
256
|
+
|
257
|
+
However, because patches use the class-level Cutoff methods, this only works
|
258
|
+
when calling cutoff methods manually.
|
259
|
+
|
260
|
+
Nested Cutoffs
|
261
|
+
-----------------
|
262
|
+
|
263
|
+
When using the Cutoff class methods, it is possible to nest multiple Cutoff
|
264
|
+
contexts with `.wrap` or `.start`.
|
265
|
+
|
266
|
+
```ruby
|
267
|
+
Cutoff.wrap(10) do
|
268
|
+
# This outer block has a timeout of 10 seconds
|
269
|
+
Cutoff.wrap(3) do
|
270
|
+
# But this inner block is only allowed to take 3 seconds
|
271
|
+
end
|
272
|
+
end
|
273
|
+
```
|
274
|
+
|
275
|
+
A child cutoff can never be set for longer than the remaining time of its parent
|
276
|
+
cutoff. So if a child is created for longer than the remaining allowed time, it
|
277
|
+
will be reduced to the remaining time of the outer cutoff.
|
278
|
+
|
279
|
+
```ruby
|
280
|
+
Cutoff.wrap(5) do
|
281
|
+
sleep(4)
|
282
|
+
# There is only 1 second remaining in the parent
|
283
|
+
Cutoff.wrap(3) do
|
284
|
+
# So this inner block will only have 1 second to execute
|
285
|
+
end
|
286
|
+
end
|
287
|
+
```
|
288
|
+
|
289
|
+
About the Timer
|
290
|
+
-------------------
|
291
|
+
|
292
|
+
Cutoff tries to use the best timer available on whatever platform it's running
|
293
|
+
on. If a monotonic clock is available, that will be used, or failing that, if
|
294
|
+
concurrent-ruby is loaded, that will be used. If neither is available,
|
295
|
+
`Time.now` is used.
|
296
|
+
|
297
|
+
This mean that Cutoff tries its best to prevent time from travelling backwards.
|
298
|
+
However, the clock uniformity, resolution, and stability is determined by the
|
299
|
+
system Cutoff is running on.
|
300
|
+
|
301
|
+
Manual start and stop
|
302
|
+
----------------------
|
303
|
+
|
304
|
+
If you find that `Cutoff.wrap` is too limiting for some integrations, Cutoff
|
305
|
+
also provides the `start` and `stop` methods. Extra care is required to use
|
306
|
+
these to prevent a cutoff from being leaked. Every `start` call must be
|
307
|
+
accompanied by a `stop` call, otherwise the cutoff will continue to run and
|
308
|
+
could affect a context other than the intended one.
|
309
|
+
|
310
|
+
```ruby
|
311
|
+
Cutoff.start(2.5)
|
312
|
+
begin
|
313
|
+
# Execute code here
|
314
|
+
Cutoff.checkpoint!
|
315
|
+
ensure
|
316
|
+
# Always stop in an ensure statement to make sure an exception cannot leave
|
317
|
+
# a cutoff running
|
318
|
+
Cutoff.stop
|
319
|
+
end
|
320
|
+
|
321
|
+
# Nested cutoffs are still supported
|
322
|
+
outer = Cutoff.start(10)
|
323
|
+
begin
|
324
|
+
# Outer 10s cutoff is used here
|
325
|
+
Cutoff.checkpoint!
|
326
|
+
|
327
|
+
inner = Cutoff.start(5)
|
328
|
+
begin
|
329
|
+
# Inner 5s cutoff is used here
|
330
|
+
Cutoff.checkpoint!
|
331
|
+
ensure
|
332
|
+
# Stops the inner cutoff
|
333
|
+
# We don't need to pass the instance here, but it does prevent some types of mistakes
|
334
|
+
Cutoff.stop(inner)
|
335
|
+
end
|
336
|
+
ensure
|
337
|
+
# Stops the outer cutoff
|
338
|
+
Cutoff.stop(outer)
|
339
|
+
end
|
340
|
+
|
341
|
+
Cutoff.start(10)
|
342
|
+
Cutoff.start(5)
|
343
|
+
begin
|
344
|
+
# Code here
|
345
|
+
ensure
|
346
|
+
# This stops all cutoffs
|
347
|
+
Cutoff.clear_all
|
348
|
+
end
|
349
|
+
```
|
350
|
+
|
351
|
+
Be careful, you can easily make a mistake when using this API, so prefer `.wrap`
|
352
|
+
when possible.
|
353
|
+
|
354
|
+
Design Philosophy
|
355
|
+
-------------------
|
356
|
+
|
357
|
+
Cutoff is designed to only stop code execution at predictable points. It will
|
358
|
+
never interrupt a running program unless:
|
359
|
+
|
360
|
+
- `checkpoint!` is called
|
361
|
+
- a network timeout is exceeded
|
362
|
+
|
363
|
+
Patches such as the current Mysql2 patch are designed to ease the burden on
|
364
|
+
developers to manually call `checkpoint!` or configure network timeouts. The
|
365
|
+
ruby `Timeout` class is not used. See Julia Evans' post on [Why Ruby's Timeout
|
366
|
+
is dangerous][julia_evans].
|
367
|
+
|
368
|
+
Patches are only applied by explicit opt-in, and Cutoff can always be used as a
|
369
|
+
standalone library.
|
370
|
+
|
371
|
+
[julia_evans]: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
|
372
|
+
[kir shatrov]: https://kirshatrov.com/posts/scaling-mysql-stack-part-2-deadlines/
|
373
|
+
[api docs]: https://www.rubydoc.info/github/justinhoward/cutoff/master
|
data/lib/cutoff.rb
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal:true
|
2
|
+
|
3
|
+
require_relative 'cutoff/version'
|
4
|
+
require_relative 'cutoff/error'
|
5
|
+
require_relative 'cutoff/patch'
|
6
|
+
|
7
|
+
class Cutoff
|
8
|
+
CURRENT_STACK_KEY = 'cutoff_deadline_stack'
|
9
|
+
private_constant :CURRENT_STACK_KEY
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Get the current {Cutoff} if one is set
|
13
|
+
def current
|
14
|
+
Thread.current[CURRENT_STACK_KEY]&.last
|
15
|
+
end
|
16
|
+
|
17
|
+
# Add a new {Cutoff} to the stack
|
18
|
+
#
|
19
|
+
# This {Cutoff} will be specific to this thread
|
20
|
+
#
|
21
|
+
# If a cutoff is already started for this thread, then `start` uses the
|
22
|
+
# minimum of the current remaining time and the given time
|
23
|
+
#
|
24
|
+
# @param seconds [Float, Integer] The number of seconds for the cutoff. May
|
25
|
+
# be overridden if there is an active cutoff and it has less remaining
|
26
|
+
# time.
|
27
|
+
# @return [Cutoff] The {Cutoff} instance
|
28
|
+
def start(seconds)
|
29
|
+
seconds = [seconds, current.seconds_remaining].min if current
|
30
|
+
cutoff = Cutoff.new(seconds)
|
31
|
+
Thread.current[CURRENT_STACK_KEY] ||= []
|
32
|
+
Thread.current[CURRENT_STACK_KEY] << cutoff
|
33
|
+
cutoff
|
34
|
+
end
|
35
|
+
|
36
|
+
# Remove the top {Cutoff} from the stack
|
37
|
+
#
|
38
|
+
# @param cutoff [Cutoff] If given, the top instance will only be removed
|
39
|
+
# if it matches the given cutoff instance
|
40
|
+
# @return [Cutoff, nil] If a cutoff was removed it is returned
|
41
|
+
def stop(cutoff = nil)
|
42
|
+
stack = Thread.current[CURRENT_STACK_KEY]
|
43
|
+
return unless stack
|
44
|
+
|
45
|
+
top = stack.last
|
46
|
+
stack.pop if cutoff.nil? || top == cutoff
|
47
|
+
clear_all if stack.empty?
|
48
|
+
|
49
|
+
cutoff
|
50
|
+
end
|
51
|
+
|
52
|
+
# Clear the entire stack for this thread
|
53
|
+
#
|
54
|
+
# @return [void]
|
55
|
+
def clear_all
|
56
|
+
Thread.current[CURRENT_STACK_KEY] = nil
|
57
|
+
end
|
58
|
+
|
59
|
+
# Wrap a block in a cutoff
|
60
|
+
#
|
61
|
+
# Same as calling {.start} and {.stop} manually, but safer since
|
62
|
+
# you can't forget to stop a cutoff and it handles exceptions raised
|
63
|
+
# inside the block
|
64
|
+
#
|
65
|
+
# @see .start
|
66
|
+
# @see .stop
|
67
|
+
# @return The value that returned from the block
|
68
|
+
def wrap(seconds)
|
69
|
+
cutoff = start(seconds)
|
70
|
+
yield cutoff
|
71
|
+
ensure
|
72
|
+
stop(cutoff)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Raise an exception if there is an active expired cutoff
|
76
|
+
#
|
77
|
+
# Does nothing if no active cutoff is set
|
78
|
+
#
|
79
|
+
# @raise CutoffExceededError If there is an active expired cutoff
|
80
|
+
# @return [void]
|
81
|
+
def checkpoint!
|
82
|
+
cutoff = current
|
83
|
+
return unless cutoff
|
84
|
+
|
85
|
+
cutoff.checkpoint!
|
86
|
+
end
|
87
|
+
|
88
|
+
if defined?(Process::CLOCK_MONOTONIC_RAW)
|
89
|
+
# The current time
|
90
|
+
#
|
91
|
+
# If it is available, this will use a monotonic clock. This is a clock
|
92
|
+
# that always moves forward in time. If that is not available on this
|
93
|
+
# system, `Time.now` will be used
|
94
|
+
#
|
95
|
+
# @return [Float] The current time as a float
|
96
|
+
def now
|
97
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC_RAW)
|
98
|
+
end
|
99
|
+
elsif defined?(Process::CLOCK_MONOTONIC)
|
100
|
+
def now
|
101
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
102
|
+
end
|
103
|
+
elsif Gem.loaded_specs['concurrent-ruby']
|
104
|
+
require 'concurrent-ruby'
|
105
|
+
|
106
|
+
def now
|
107
|
+
Concurrent.monotonic_time
|
108
|
+
end
|
109
|
+
else
|
110
|
+
def now
|
111
|
+
Time.now.to_f
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# @return [Float] The total number of seconds for this cutoff
|
117
|
+
attr_reader :allowed_seconds
|
118
|
+
|
119
|
+
# Create a new cutoff
|
120
|
+
#
|
121
|
+
# The timer starts immediately upon creation
|
122
|
+
#
|
123
|
+
# @param allowed_seconds [Integer, Float] The total number of seconds to allow
|
124
|
+
def initialize(allowed_seconds)
|
125
|
+
@allowed_seconds = allowed_seconds.to_f
|
126
|
+
@start_time = Cutoff.now
|
127
|
+
end
|
128
|
+
|
129
|
+
# The number of seconds left on the clock
|
130
|
+
#
|
131
|
+
# @return [Float] The number of seconds
|
132
|
+
def seconds_remaining
|
133
|
+
@allowed_seconds - elapsed_seconds
|
134
|
+
end
|
135
|
+
|
136
|
+
# The number of milliseconds left on the clock
|
137
|
+
#
|
138
|
+
# @return [Float] The number of milliseconds
|
139
|
+
def ms_remaining
|
140
|
+
seconds_remaining * 1000
|
141
|
+
end
|
142
|
+
|
143
|
+
# The number of seconds elapsed since this {Cutoff} was created
|
144
|
+
#
|
145
|
+
# @return [Float] The number of seconds
|
146
|
+
def elapsed_seconds
|
147
|
+
Cutoff.now - @start_time
|
148
|
+
end
|
149
|
+
|
150
|
+
# Has the Cutoff been exceeded?
|
151
|
+
#
|
152
|
+
# @return [Boolean] True if the timer expired
|
153
|
+
def exceeded?
|
154
|
+
seconds_remaining.negative?
|
155
|
+
end
|
156
|
+
|
157
|
+
# Raises an error if this Cutoff has been exceeded
|
158
|
+
#
|
159
|
+
# @raise CutoffExceededError If there is an active expired cutoff
|
160
|
+
# @return [void]
|
161
|
+
def checkpoint!
|
162
|
+
raise CutoffExceededError, self if exceeded?
|
163
|
+
|
164
|
+
nil
|
165
|
+
end
|
166
|
+
end
|
data/lib/cutoff/error.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal:true
|
2
|
+
|
3
|
+
class Cutoff
|
4
|
+
# The Cutoff base error class
|
5
|
+
class CutoffError < StandardError
|
6
|
+
private
|
7
|
+
|
8
|
+
def message_with_meta(message, **meta)
|
9
|
+
"#{message}: #{format_meta(**meta)}"
|
10
|
+
end
|
11
|
+
|
12
|
+
def format_meta(**meta)
|
13
|
+
meta.map { |key, value| "#{key}=#{value}" }.join(' ')
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Raised by {Cutoff#checkpoint!} if the time has been exceeded
|
18
|
+
class CutoffExceededError < CutoffError
|
19
|
+
attr_reader :cutoff
|
20
|
+
|
21
|
+
def initialize(cutoff)
|
22
|
+
@cutoff = cutoff
|
23
|
+
|
24
|
+
super(message_with_meta(
|
25
|
+
'Cutoff exceeded',
|
26
|
+
allowed_seconds: cutoff.allowed_seconds,
|
27
|
+
elapsed_seconds: cutoff.elapsed_seconds
|
28
|
+
))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/cutoff/patch.rb
ADDED
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'strscan'
|
4
|
+
require 'mysql2'
|
5
|
+
|
6
|
+
class Cutoff
|
7
|
+
module Patch
|
8
|
+
# Sets the max execution time for SELECT queries if there is an active
|
9
|
+
# cutoff and it has time remaining
|
10
|
+
module Mysql2
|
11
|
+
# Overrides `Mysql2::Client#query` to insert a MAX_EXECUTION_TIME query
|
12
|
+
# hint with the remaining cutoff time
|
13
|
+
#
|
14
|
+
# If the cutoff is already exceeded, the query will not be executed and
|
15
|
+
# a {CutoffExceededError} will be raised
|
16
|
+
#
|
17
|
+
# @see Mysql2::Client#query
|
18
|
+
# @raise CutoffExceededError If the cutoff is exceeded. The query will not
|
19
|
+
# be executed in this case.
|
20
|
+
def query(sql, options = {})
|
21
|
+
cutoff = Cutoff.current
|
22
|
+
return super unless cutoff
|
23
|
+
|
24
|
+
cutoff.checkpoint!
|
25
|
+
sql = QueryWithMaxTime.new(sql, cutoff.ms_remaining.ceil).to_s
|
26
|
+
super
|
27
|
+
end
|
28
|
+
|
29
|
+
# Parses a query and inserts a MAX_EXECUTION_TIME query hint if possible
|
30
|
+
#
|
31
|
+
# @private
|
32
|
+
class QueryWithMaxTime
|
33
|
+
def initialize(query, max_execution_time_ms)
|
34
|
+
@scanner = StringScanner.new(query.dup)
|
35
|
+
@max_execution_time_ms = max_execution_time_ms
|
36
|
+
@found_select = false
|
37
|
+
@found_hint = false
|
38
|
+
@hint_pos = nil
|
39
|
+
@insert_space = false
|
40
|
+
@insert_trailing_space = false
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_s
|
44
|
+
return @scanner.string if @scanner.eos?
|
45
|
+
|
46
|
+
# Loop through tokens like "WORD " or "/* "
|
47
|
+
while @scanner.scan(/(\S+)\s+/)
|
48
|
+
# Get the word part. None of our tokens care about case
|
49
|
+
handle_token(@scanner[1].downcase)
|
50
|
+
end
|
51
|
+
|
52
|
+
return @scanner.string unless @found_select
|
53
|
+
|
54
|
+
insert_hint
|
55
|
+
@scanner.string
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def hint
|
61
|
+
"MAX_EXECUTION_TIME(#{@max_execution_time_ms})"
|
62
|
+
end
|
63
|
+
|
64
|
+
def handle_token(token)
|
65
|
+
if token.start_with?('--')
|
66
|
+
line_comment
|
67
|
+
elsif token.start_with?('/*+')
|
68
|
+
hint_comment
|
69
|
+
elsif token.start_with?('/*')
|
70
|
+
block_comment
|
71
|
+
elsif token.start_with?('select')
|
72
|
+
select
|
73
|
+
else
|
74
|
+
other
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def insert_hint
|
79
|
+
@scanner.string.insert(@hint_pos, ' ') if @insert_trailing_space
|
80
|
+
|
81
|
+
if @found_hint
|
82
|
+
# If we found an existing hint, insert our new hint there
|
83
|
+
@scanner.string.insert(@hint_pos, hint)
|
84
|
+
elsif @found_select
|
85
|
+
# Otherwise if we found a select, place our hint right after it
|
86
|
+
@scanner.string.insert(@hint_pos, "/*+ #{hint} */")
|
87
|
+
end
|
88
|
+
|
89
|
+
@scanner.string.insert(@hint_pos, ' ') if @insert_space
|
90
|
+
end
|
91
|
+
|
92
|
+
def line_comment
|
93
|
+
# \R matches cross-platform newlines
|
94
|
+
# so we skip until the end of the line
|
95
|
+
@scanner.skip_until(/\R/)
|
96
|
+
end
|
97
|
+
|
98
|
+
def block_comment
|
99
|
+
# Go back to the beginning of the comment then scan until the end
|
100
|
+
# This handles block comments that don't contain whitespace
|
101
|
+
@scanner.unscan
|
102
|
+
@scanner.skip_until(%r{\*/\s*})
|
103
|
+
end
|
104
|
+
|
105
|
+
def hint_comment
|
106
|
+
# We can just treat this as a normal block comment if we haven't seen
|
107
|
+
# a select yet
|
108
|
+
return block_comment unless @found_select
|
109
|
+
|
110
|
+
@found_hint = true
|
111
|
+
# Go back to the beginning of the comment
|
112
|
+
# This is so we can handle comments that don't have internal
|
113
|
+
# whitespace
|
114
|
+
@scanner.unscan
|
115
|
+
# Now skip past just the start of the comment so we don't detect it
|
116
|
+
# on the next line
|
117
|
+
@scanner.skip(%r{/\*\+})
|
118
|
+
# Scan until the end of the comment
|
119
|
+
# Also detect the last word and trailing whitespace if it exists
|
120
|
+
@scanner.scan_until(%r{(\S*)(\s*)\*/})
|
121
|
+
# Now step back to the beginning of the */
|
122
|
+
# If there was trailing whitespace, also subtract that
|
123
|
+
# so that we're at the start of the trailing whitespace
|
124
|
+
# That's where we want to put our hint
|
125
|
+
@hint_pos = @scanner.pos - 2 - @scanner[2].size
|
126
|
+
# We only want to insert an extra space to the left of our
|
127
|
+
# hint if there was already a hint (it's possible to have an
|
128
|
+
# empty hint comment). So check if there was a word there.
|
129
|
+
@insert_space = !@scanner[1].empty?
|
130
|
+
|
131
|
+
# Once we find our position, we're done
|
132
|
+
@scanner.terminate
|
133
|
+
end
|
134
|
+
|
135
|
+
def select
|
136
|
+
# If we encounter a select, we're ready to place our hint comment
|
137
|
+
@scanner.unscan
|
138
|
+
word = @scanner.scan(/\w+/)
|
139
|
+
|
140
|
+
# Make sure our word is actually select
|
141
|
+
# We only checked that it starts with select before
|
142
|
+
return other unless word.casecmp('select')
|
143
|
+
|
144
|
+
@found_select = true
|
145
|
+
@hint_pos = @scanner.pos
|
146
|
+
|
147
|
+
# If the select has space after it, we want to also
|
148
|
+
# insert one later
|
149
|
+
if @scanner.scan(/\s+/)
|
150
|
+
@insert_space = true
|
151
|
+
elsif @scanner.scan(/\*/)
|
152
|
+
# Handle SELECT* since it needs to have an extra space inserted
|
153
|
+
# after the hint comment
|
154
|
+
@insert_trailing_space = true
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def other
|
159
|
+
# If we encounter any other token, we're done
|
160
|
+
# Either we found the select or we found another token
|
161
|
+
# that indicates we should not insert a hint
|
162
|
+
@scanner.terminate
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
Mysql2::Client.prepend(Cutoff::Patch::Mysql2)
|
metadata
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: cutoff
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Justin Howard
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-07-20 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.10'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.10'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rubocop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.81.0
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.81.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rubocop-rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.38.1
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 1.38.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: timecop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.9'
|
62
|
+
- - "<"
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '1.0'
|
65
|
+
type: :development
|
66
|
+
prerelease: false
|
67
|
+
version_requirements: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0.9'
|
72
|
+
- - "<"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '1.0'
|
75
|
+
description:
|
76
|
+
email:
|
77
|
+
- jmhoward0@gmail.com
|
78
|
+
executables: []
|
79
|
+
extensions: []
|
80
|
+
extra_rdoc_files: []
|
81
|
+
files:
|
82
|
+
- ".yardopts"
|
83
|
+
- CHANGELOG.md
|
84
|
+
- LICENSE.txt
|
85
|
+
- README.md
|
86
|
+
- lib/cutoff.rb
|
87
|
+
- lib/cutoff/error.rb
|
88
|
+
- lib/cutoff/patch.rb
|
89
|
+
- lib/cutoff/patch/mysql2.rb
|
90
|
+
- lib/cutoff/version.rb
|
91
|
+
homepage: https://github.com/justinhoward/cutoff
|
92
|
+
licenses:
|
93
|
+
- MIT
|
94
|
+
metadata:
|
95
|
+
changelog_uri: https://github.com/justinhoward/cutoff/blob/master/CHANGELOG.md
|
96
|
+
documentation_uri: https://www.rubydoc.info/gems/cutoff/0.1.0
|
97
|
+
post_install_message:
|
98
|
+
rdoc_options: []
|
99
|
+
require_paths:
|
100
|
+
- lib
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '2.3'
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
requirements: []
|
112
|
+
rubygems_version: 3.1.2
|
113
|
+
signing_key:
|
114
|
+
specification_version: 4
|
115
|
+
summary: Deadlines for ruby
|
116
|
+
test_files: []
|