Skip to content

Commit

Permalink
Merge pull request #1 from Talkdesk/bootstrap-gem
Browse files Browse the repository at this point in the history
Bootstrap first gem version
  • Loading branch information
ivoanjo authored Jun 29, 2017
2 parents a898999 + e0fa377 commit 6111872
Show file tree
Hide file tree
Showing 17 changed files with 767 additions and 0 deletions.
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/.bundle/
/.yardoc
/Gemfile.lock
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/

# rspec failure tracking
.rspec_status
spec/examples.txt
2 changes: 2 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--require spec_helper
--color
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jruby-9.1.12.0
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
source 'https://rubygems.org'

# Specify your gem's dependencies in warm-blanket.gemspec
gemspec
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# WarmBlanket

**WarmBlanket is still a prototype. YMMV**

WarmBlanket is a Ruby gem for warming up web services on boot. Its main target are JRuby web services, although it is not JRuby-specific in any way.

* [WarmBlanket](#warmblanket)
* [How the magic happens](#how-the-magic-happens)
* [Why do we need to warm up web services?](#why-do-we-need-to-warm-up-web-services)
* [What does WarmBlanket do?](#what-does-warmblanket-do)
* [How does WarmBlanket work?](#how-does-warmblanket-work)
* [Limitations/caveats](#limitationscaveats)
* [How can I make use of it?](#how-can-i-make-use-of-it)
* [Installation](#installation)
* [Configuration settings](#configuration-settings)
* [Configuring endpoints to be called](#configuring-endpoints-to-be-called)
* [Trigger warmup](#trigger-warmup)

# How the magic happens

## Why do we need to warm up web services?

When the Java Virtual Machine (JVM) starts, it starts by interpreting Java bytecode. As it starts to detect code that runs often, it just-in-time compiles that code into native machine code, improving performance.

This is a known challenge for most JVMs, and the same applies to JRuby applications, which also run on the JVM.

A widely-documented solution to this problem is to perform a warm-up step when starting a service:

* <https://landing.google.com/sre/book/chapters/load-balancing-datacenter.html#unpredictable-performance-factors-JMs7i7trCj>
* <http://www.brendangregg.com/blog/2016-09-28/java-warmup.html>
* <https://devcenter.heroku.com/articles/warming-up-a-java-process>

## What does WarmBlanket do?

WarmBlanket warms services by performing repeated web requests for a configurable number of seconds. After that time, it closes shop and you'll never hear about it until the next service restart or deploy.

## How does WarmBlanket work?

WarmBlanket spawns a configurable number of background threads that run inside the service process, and then uses an http client to perform local requests to the web server, simulating load.

As it simulates requests, the JVM is warmed up and thus when real requests come in, no performance degradation is observed.

## Limitations/caveats

We strongly recommend that any services using WarmBlanket, if deployed on Heroku, use [Preboot](https://devcenter.heroku.com/articles/preboot). Preboot allows a service instance to be warmed up for 3 minutes before Heroku starts sending live traffic its way, which is preferable to doing it live.

# How can I make use of it?

To make use of WarmBlanket, you'll need to follow the next sections, which will guide you through installing, configuring and enabling the gem.

## Installation

To install using Bundler, add the following to your `Gemfile`:

```ruby
gem 'warm-blanket', '~> 0.1',
git: 'https://github.com/Talkdesk/warm-blanket.git'
```

To install a particular version, add the `tag` option:

```ruby
gem 'warm-blanket', '~> 0.1',
git: 'https://github.com/Talkdesk/warm-blanket.git',
tag: 'v0.1.0'
```

## Configuration settings

This gem can be configured via the following environment variables:

* `PORT`: Local webserver port (automatically set on Heroku)
* `WARMBLANKET_ENABLED`: Enable warmup (defaults to `false`; `true` or `1` enables)
* `WARMBLANKET_WARMUP_THREADS`: Number of warmup threads to use (defaults to `2`)
* `WARMBLANKET_WARMUP_TIME_SECONDS`: Time, in seconds, during which to warm up the service (defaults to `150`)

### Configuring endpoints to be called

Configure endpoints to be called as follows (on a `config/warm_blanket.rb`:

```ruby
require 'warm-blanket'

WarmBlanket.configure do |config|
common_headers = {
'X-Api-Key': ENV['API_KEY'].split(',').first,
}

config.endpoints = [
{get: '/apps', headers: common_headers},
{get: '/', headers: common_headers},
]
end
```

Other HTTP verbs are supported (and you can pass in a `body` key if needed), but be careful about side effects from such verbs. And if there's no side effect from a `POST` or `PUT`, do consider if it shouldn't be a `GET` instead ;)

## Trigger warmup

Add the following to the end of your `config.ru` file:

```ruby
WarmBlanket.trigger_warmup
```
17 changes: 17 additions & 0 deletions bin/pry
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'pry' is installed as part of a gem, and
# this file is here to facilitate running it.
#

require 'pathname'
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
Pathname.new(__FILE__).realpath)

require 'rubygems'
require 'bundler/setup'

load Gem.bin_path('pry', 'pry')
17 changes: 17 additions & 0 deletions bin/rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'rspec' is installed as part of a gem, and
# this file is here to facilitate running it.
#

require 'pathname'
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
Pathname.new(__FILE__).realpath)

require 'rubygems'
require 'bundler/setup'

load Gem.bin_path('rspec-core', 'rspec')
35 changes: 35 additions & 0 deletions lib/warm-blanket.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require 'warm_blanket/version'
require 'warm_blanket/orchestrator'

require 'dry-configurable'
require 'logging'

module WarmBlanket
extend Dry::Configurable

# Endpoints to be called for warmup, see README
setting :endpoints, [], reader: true

setting :logger, Logging.logger[self], reader: true

# Local webserver port
setting :port, ENV['PORT'], reader: true

# Enable warmup
setting :enabled, ENV['WARMBLANKET_ENABLED'], reader: true

# Number of threads to use
setting :warmup_threads, Integer(ENV['WARMBLANKET_WARMUP_THREADS'] || 2), reader: true

# Time, in seconds, during which to warm up the service
setting :warmup_time_seconds, Float(ENV['WARMBLANKET_WARMUP_TIME_SECONDS'] || 150), reader: true

def self.trigger_warmup(logger: WarmBlanket.config.logger, orchestrator_factory: Orchestrator)
unless [true, 'true', '1'].include?(WarmBlanket.config.enabled)
logger.info "WarmBlanket not enabled, ignoring trigger_warmup"
return false
end

orchestrator_factory.new.call
end
end
116 changes: 116 additions & 0 deletions lib/warm_blanket/orchestrator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

require 'warm_blanket/requester'
require 'warm_blanket/wait_for_port'

module WarmBlanket
# Orchestrates threads to wait for the port to open and to perform the warmup requests
class Orchestrator

DEFAULT_HEADERS = {
'X-Forwarded-Proto': 'https',
'X-Request-Id': 'WarmBlanket',
'X-Platform-Tid': 'WarmBlanket',
'X-Client-Id': 'WarmBlanket',
'X-Account': 'WarmBlanket',
}.freeze

private

attr_reader :requester_factory
attr_reader :wait_for_port_factory
attr_reader :logger
attr_reader :endpoints
attr_reader :hostname
attr_reader :port
attr_reader :warmup_threads
attr_reader :warmup_time_seconds

public

def initialize(
requester_factory: Requester,
wait_for_port_factory: WaitForPort,
logger: WarmBlanket.config.logger,
endpoints: WarmBlanket.config.endpoints,
hostname: 'localhost',
port: WarmBlanket.config.port,
warmup_threads: WarmBlanket.config.warmup_threads,
warmup_time_seconds: WarmBlanket.config.warmup_time_seconds
)
raise "Warmup threads cannot be less than 1 (got #{warmup_threads})" if warmup_threads < 1

@requester_factory = requester_factory
@wait_for_port_factory = wait_for_port_factory
@logger = logger
@endpoints = endpoints
@hostname = hostname
@port = port
@warmup_threads = warmup_threads
@warmup_time_seconds = warmup_time_seconds
end

def call
safely_spawn_thread do
logger.debug 'Started orchestrator thread'
orchestrate
end
end

private

def safely_spawn_thread(&block)
Thread.new do
begin
block.call
rescue => e
logger.error "Caught error that caused background thread to die #{e.class}: #{e.message}"
end
end
end

def orchestrate
success = wait_for_port_to_open

spawn_warmup_threads if success
end

def wait_for_port_to_open
wait_for_port_factory.new(port: port).call
end

def spawn_warmup_threads
# Create remaining threads
(warmup_threads - 1).times do
safely_spawn_thread do
perform_warmup_requests
end
end

# Reuse current thread
perform_warmup_requests
end

def perform_warmup_requests
success = false
logger.debug "Starting warmup requests"

warmup_start = Time.now
warmup_deadline = warmup_start + warmup_time_seconds

requester = requester_factory.new(
base_url: "http://#{hostname}:#{port}",
default_headers: DEFAULT_HEADERS,
endpoints: endpoints,
)

while Time.now < warmup_deadline
requester.call
end

success = true
ensure
logger.info "Finished warmup work #{success ? 'successfully' : 'with error'}"
end
end
end
80 changes: 80 additions & 0 deletions lib/warm_blanket/requester.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
require 'faraday'

module WarmBlanket
# Issues one request per call to the configured endpoint
class Requester

InvalidHTTPVerb = Class.new(StandardError)

private

SUPPORTED_VERBS = [:get, :post, :put].freeze
private_constant :SUPPORTED_VERBS

attr_reader :base_url
attr_reader :default_headers
attr_reader :endpoints
attr_reader :logger
attr_reader :connection_factory

attr_accessor :next_endpoint_position

public

def initialize(base_url:, default_headers:, endpoints:, logger: WarmBlanket.config.logger, connection_factory: Faraday)
@base_url = base_url
@default_headers = default_headers
@endpoints = endpoints
@logger = logger
@connection_factory = connection_factory
@next_endpoint_position = 0
end

def call
connection = connection_factory.new(url: base_url)

endpoint = next_endpoint

http_verb = extract_verb(endpoint)

logger.debug "Requesting #{endpoint.fetch(http_verb)}"

response = connection.public_send(http_verb) do |request|
apply_headers(request, default_headers)
apply_headers(request, endpoint[:headers])
request.url(endpoint.fetch(http_verb))
request.body = endpoint[:body] if endpoint[:body]
end

if response.status == 200
logger.debug "Request successful"
else
logger.warn "Request to #{endpoint.fetch(:get)} failed with code #{response.status}"
end

nil
end

private

def apply_headers(request, headers)
headers&.each do |header, value|
request.headers[header.to_s] = value
end
end

def next_endpoint
next_endpoint = endpoints[next_endpoint_position]
self.next_endpoint_position = (next_endpoint_position + 1) % endpoints.size
next_endpoint
end

def extract_verb(endpoint)
SUPPORTED_VERBS.each do |verb|
return verb if endpoint.key?(verb)
end

raise InvalidHTTPVerb, "Unsupported or missing HTTP verb for request: #{endpoint.inspect}"
end
end
end
Loading

0 comments on commit 6111872

Please sign in to comment.