/cutoff

Deadlines for Ruby

Primary LanguageRubyMIT LicenseMIT

Cutoff

Gem Version CI Code Quality Code Coverage Inline docs

A deadlines library for Ruby inspired by Shopify and Kir Shatrov's blog series.

Cutoff.wrap(5) do
  sleep(4)
  Cutoff.checkpoint! # still have time left
  sleep(2)
  Cutoff.checkpoint! # raises an error
end

It has built-in patches for Mysql2 and Net::HTTP to auto-insert checkpoints and timeouts.

require 'cutoff/patch/mysql2'

client = Mysql2::Client.new
Cutoff.wrap(5) do
  client.query('SELECT * FROM dual WHERE sleep(2)')

  # Cutoff will automatically insert a /*+ MAX_EXECUTION_TIME(3000) */
  # hint so that MySQL will terminate the query after the time remaining
  #
  # Or if time already expired, this will raise an error and not be executed
  client.query('SELECT * FROM dual WHERE sleep(1)')
end

Why use deadlines?

If you've already implemented timeouts for your networked dependencies, then you can be sure that no single HTTP request or database query can take longer than the time allotted to it.

For example, let's say you set a query timeout of 3 seconds. That means no single query will take longer than 3 seconds. However, imagine a bad controller action or background job executes 100 slow queries. In that case, the queries add up to 300 seconds, much too long.

Deadlines keep track of the total elapsed time in a request or job and interrupt it if it takes too long.

Installation

Add it to your Gemfile:

gem 'cutoff'

Or install it manually:

gem install cutoff

API Documentation

API docs can be read on rubydoc.info, inline in the source code, or you can generate them yourself with Ruby yard:

bin/yardoc

Then open doc/index.html in your browser.

Usage

The simplest way to use Cutoff is to use its class methods, although it can be used in an object-oriented manner as well.

Wrapping a block

Cutoff.wrap(3.5) do # number of allowed seconds for this block
  # Do something time-consuming here

  # At a good stopping point, call checkpoint!
  # If the allowed time is exceeded, this raises a Cutoff::CutoffExceededError
  # otherwise, it does nothing
  Cutoff.checkpoint!

  # Now continue executing
end

Creating your own instance

cutoff = Cutoff.new(6.4)
sleep(10)
cutoff.checkpoint! # Raises Cutoff::CutoffExceededError

Getting cutoff details

Cutoff has some instance methods to get information about the time remaining, etc.

# If you're using Cutoff class methods, you can get the current instance
cutoff = Cutoff.current # careful, this will be nil if a cutoff isn't running

Once you have an instance, either by creating your own or from .current, you have access to these methods.

cutoff = Cutoff.current

# These return Floats
cutoff.allowed_seconds # Total seconds allowed (the seconds given when cutoff was started)
cutoff.seconds_remaining # Seconds left
cutoff.elapsed_seconds # Seconds since the cutoff was started
cutoff.ms_remaining # Milliseconds left

cutoff.exceeded? # True if the cutoff is expired

Patches

Cutoff is in early stages, but it aims to provide patches for common networked dependencies. Patches automatically insert useful checkpoints and timeouts. The patches so far are for mysql2 and Net::HTTP. They are not loaded by default, so you need to require them manually.

For example, to load the Mysql2 patch:

# In your Gemfile
gem 'cutoff', require: %w[cutoff cutoff/patch/mysql2]
# Or manually
require 'cutoff'
require 'cutoff/patch/mysql2'

Mysql2

Once it is enabled, any Mysql2::Client object will respect the current class-level cutoff if one is set.

require 'cutoff/patch/mysql2'

client = Mysql2::Client.new
Cutoff.wrap(3) do
  sleep(4)

  # This query will not be executed because the time is already expired
  client.query('SELECT * FROM users')
end

Cutoff.wrap(3) do
  sleep(1)

  # There are 2 seconds left, so a MAX_EXECUTION_TIME query hint is added
  # to inform MySQL we only have 2 seconds to execute this query
  # The executed query will be "SELECT /*+ MAX_EXECUTION_TIME(2000) */ * FROM users"
  client.query('SELECT * FROM users')

  # MySQL only supports MAX_EXECUTION_TIME for SELECTs so no query hint here
  client.query("INSERT INTO users(first_name) VALUES('Joe')")

  sleep(3)

  # We don't even execute this query because time is already expired
  # This limit applies to all queries, including INSERTS, etc
  client.query('SELECT * FROM users')
end

Net::HTTP

Once it is enabled, any Net::HTTP requests will respect the current class-level cutoff if one is set.

require 'cutoff/patch/net_http'

Cutoff.wrap(3) do
  sleep(5)

  # The cutoff is expired, so this hits a checkpoint and will not be executed
  Net::HTTP.get(URI.parse('http://example.com'))
end

Cutoff.wrap(3) do
  sleep(1.5)

  # The cutoff has 1.5 seconds left, so this request will be executed
  # open_timeout, read_timeout, and write_timeout (Ruby >= 2.6) will each
  # be set to 1.5
  # This means the overall time can be > 1.5 since the combined phases can take
  # up to 4.5 seconds
  Net::HTTP.get(URI.parse('http://example.com'))
end

Selecting Checkpoints

In some cases, you may want to select some checkpoints to use, but not others. For example, you may want to run some code that contains MySQL queries, but not use the mysql2 patch. The exclude and only options support this.

Cutoff.wrap(10, exclude: :mysql2) do
  # The mysql2 patch won't be used here
end

Cutoff.wrap(10, only: %i[foo bar]) do
  # These checkpoints will be used
  Cutoff.checkpoint!(:foo)
  Cutoff.checkpoint!(:bar)

  # These checkpoints will be skipped
  Cutoff.checkpoint!(:asdf)
  Cutoff.checkpoint!
end

Timing a Rails Controller

One use of a cutoff is to add a deadline to a Rails controller action. This is typically preferable to approaches like Rack::Timeout that use the dangerous Timeout class.

Cutoff includes a built-in integration for this purpose. If Rails is installed, the #cutoff class method is available in your controllers.

class ApplicationController < ActionController::Base
  # You may want to set a long global cutoff, but it's not required
  cutoff 30
end

class UsersController < ApplicationController
  cutoff 5.0

  def index
    # Now in your action, you can call `checkpoint!`, or if you're using the
    # patches, checkpoints will be added automatically
    Cutoff.checkpoint!
  end
end

Just like with controller filters, you can use filters with the cutoff method.

class UsersController < ApplicationController
  # For example, use an :only filter
  cutoff 5.0, only: :index

  # Multiple calls work just fine. Last match wins
  cutoff 2.5, only: :show

  def index
    # ...
  end

  def show
    # ...
  end
end

Consider adding a global error handler for the Cutoff::CutoffExceededError in case you want to display a nice error page for timeouts.

class ApplicationController < ActionController::Base
  rescue_from Cutoff::CutoffExceededError, with: :handle_cutoff_exceeded

  def handle_cutoff_exceeded
    # Render a nice error page
  end
end

Timing Sidekiq Workers

If Sidekiq is loaded, Cutoff includes middleware to support a :cutoff option.

class MyWorker
  include Sidekiq::Worker

  sidekiq_options cutoff: 6.0

  def perform
    # ...
    Cutoff.checkpoint!
    # ...
  end
end

Disabling Cutoff for Testing and Development

When testing or debugging an application that uses Cutoff, you may want to disable Cutoff entirely. These methods are not thread-safe and not intended for production.

# This disables all cutoff timers, for both global and local instances
Cutoff.disable!
Cutoff.disabled? # => true

# Re-enable cutoff
Cutoff.enable!

Multi-threading

In multi-threaded environments, cutoff class methods are independent in each thread. That means that if you start a cutoff in one thread then start a new thread, the second thread will not inherit the cutoff from its parent thread.

Cutoff.wrap(6) do
  Thread.new do
    # This code can run as long as it wants because the class-level
    # cutoff is independent

    Cutoff.wrap(3) do
      # However, you can start a new cutoff inside the new thread and it
      # will not affect any other threads
    end
  end
end

The same rules apply to fibers. Each fiber has independent class-level cutoff instances. This means you can use Cutoff in a multi-threaded web server or job runner without worrying about thread conflicts.

If you want to use a single cutoff for multi-threading, you'll need to pass an instance of a Cutoff.

cutoff = Cutoff.new(6)
cutoff.checkpoint! # parent thread can call checkpoint!
Thread.new do
  # And the child thread can use the same cutoff
  cutoff.checkpoint!
end
end

However, because patches use the class-level Cutoff methods, this only works when calling cutoff methods manually.

Nested Cutoffs

When using the Cutoff class methods, it is possible to nest multiple Cutoff contexts with .wrap or .start.

Cutoff.wrap(10) do
  # This outer block has a timeout of 10 seconds
  Cutoff.wrap(3) do
    # But this inner block is only allowed to take 3 seconds
  end
end

A child cutoff can never be set for longer than the remaining time of its parent cutoff. So if a child is created for longer than the remaining allowed time, it will be reduced to the remaining time of the outer cutoff.

Cutoff.wrap(5) do
  sleep(4)
  # There is only 1 second remaining in the parent
  Cutoff.wrap(3) do
    # So this inner block will only have 1 second to execute
  end
end

About the Timer

Cutoff tries to use the best timer available on whatever platform it's running on. If a monotonic clock is available, that will be used, or failing that, if concurrent-ruby is loaded, that will be used. If neither is available, Time.now is used.

This mean that Cutoff tries its best to prevent time from travelling backwards. However, the clock uniformity, resolution, and stability is determined by the system Cutoff is running on.

Manual start and stop

If you find that Cutoff.wrap is too limiting for some integrations, Cutoff also provides the start and stop methods. Extra care is required to use these to prevent a cutoff from being leaked. Every start call must be accompanied by a stop call, otherwise the cutoff will continue to run and could affect a context other than the intended one.

Cutoff.start(2.5)
begin
  # Execute code here
  Cutoff.checkpoint!
ensure
  # Always stop in an ensure statement to make sure an exception cannot leave
  # a cutoff running
  Cutoff.stop
end

# Nested cutoffs are still supported
outer = Cutoff.start(10)
begin
  # Outer 10s cutoff is used here
  Cutoff.checkpoint!

  inner = Cutoff.start(5)
  begin
    # Inner 5s cutoff is used here
    Cutoff.checkpoint!
  ensure
    # Stops the inner cutoff
    # We don't need to pass the instance here, but it does prevent some types of mistakes
    Cutoff.stop(inner)
  end
ensure
  # Stops the outer cutoff
  Cutoff.stop(outer)
end

Cutoff.start(10)
Cutoff.start(5)
begin
  # Code here
ensure
  # This stops all cutoffs
  Cutoff.clear_all
end

Be careful, you can easily make a mistake when using this API, so prefer .wrap when possible.

Design Philosophy

Cutoff is designed to only stop code execution at predictable points. It will never interrupt a running program unless:

  • checkpoint! is called
  • a network timeout is exceeded

Patches are designed to ease the burden on developers to manually call checkpoint! or configure network timeouts. The ruby Timeout class is not used. See Julia Evans' post on Why Ruby's Timeout is dangerous.

Patches are only applied by explicit opt-in, and Cutoff can always be used as a standalone library.