cutoff 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ --markup markdown
2
+ --markup-provider redcarpet
3
+ --no-private
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
+ [![Gem Version](https://badge.fury.io/rb/cutoff.svg)](https://badge.fury.io/rb/cutoff)
5
+ [![CI](https://github.com/justinhoward/cutoff/workflows/CI/badge.svg)](https://github.com/justinhoward/cutoff/actions?query=workflow%3ACI+branch%3Amaster)
6
+ [![Code Quality](https://app.codacy.com/project/badge/Grade/2748da79ec294f909996a56f11caac4a)](https://www.codacy.com/gh/justinhoward/cutoff/dashboard?utm_source=github.com&utm_medium=referral&utm_content=justinhoward/cutoff&utm_campaign=Badge_Grade)
7
+ [![Code Coverage](https://app.codacy.com/project/badge/Coverage/2748da79ec294f909996a56f11caac4a)](https://www.codacy.com/gh/justinhoward/cutoff/dashboard?utm_source=github.com&utm_medium=referral&utm_content=justinhoward/cutoff&utm_campaign=Badge_Coverage)
8
+ [![Inline docs](http://inch-ci.org/github/justinhoward/cutoff.svg?branch=master)](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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal:true
2
+
3
+ class Cutoff
4
+ # Namespace for patches that are not automatically required
5
+ module Patch
6
+ end
7
+ end
@@ -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)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Cutoff
4
+ # @return [Gem::Version] The current version of the cutoff gem
5
+ def self.version
6
+ Gem::Version.new('0.1.0')
7
+ end
8
+ end
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: []