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 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: []