Rails Concurrency issue lockup - ruby-on-rails

I have a rails app where an action never finishes and then times out.
Find the diagram below for better illustration.
My rails apps action is called
The action POSTs some data to another app
The other app needs something to complete the computation and calls a different action than the first of the Rails app
The other app gets a response and finishes the computation
The other app responds to the rails apps POST request
The view is rendered accordingly
Now the issue: The other app never gets a response from the main app. After the Rails apps request times out however, the response is sent (however too late of course) so I think it is somehow cued.
I don't understand how to fix that. I use rails 5 and Puma which should be able to handle parallel calls. Its also not a local issue, same happens in prod.
I use the recommended puma.rb config from Heroku
workers Integer(ENV['WEB_CONCURRENCY'] || 2)
threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5)
threads threads_count, threads_count
preload_app!
rackup DefaultRackup
port ENV['PORT'] || 3000
environment ENV['RACK_ENV'] || 'development'
on_worker_boot do
# Worker specific setup for Rails 4.1+
# See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
ActiveRecord::Base.establish_connection
end
What do I do to fix this cueing?
Controller:
# New method
def live_preview_page
preview_locale = params[:preview_locale]
date = params[:date] # The date to preview
page_id = params[:id]
return if locale.nil? || locale =~ /not/ || date.nil?
all_templates = Template.all.order('name ASC') # Maybe move to render_live_editor_page
if date == "all"
active_modules = #page.page_modules.order(rank: :asc)
else
active_modules = #page.page_modules.order(rank: :asc).to_a.valid_for(date: date.to_date)
puts "Active modules: #{active_modules.count}"
end
active_modules_json = active_modules.each do |content_module|
content_module.body = YAML.load(content_module.body).to_json
end
response = helpers.render_preview(active_modules, all_templates, preview_locale)
renderer = ContentRenderer.new
actionController = ActionController::Base.new
rendered_helper = actionController.render_to_string(
partial: '/pages/preview-helper-snippet', locals: {
all_templates: all_templates, # For select when creating new modules
modulesData: active_modules_json, # For rendering the JSON containing the data for the editor
current_page: #page.id,
localeLinks: renderer.generateStgPreviewURLs(SettingService.get_named_locales, #page.id),
locale: preview_locale,
all_locales: SettingService.locales_for_live_editor,
all_sites_and_locales: SettingService.get_sites_and_locales
})
proxy_service = ProxyService.new
proxy_service.get_page do |error, page_wrapper|
# Note: Issue is that Vapor app generates warnings inline template : encountered \r in middle of line, treated as a mere space
rendered_body_with_helper = response.body.force_encoding("UTF-8") + rendered_helper
decorated_page = page_wrapper.gsub("__WIDGET__", rendered_body_with_helper)
render inline: decorated_page
return
end
end
Helper
def render_preview(active_modules, all_templates, preview_locale)
req = Request.new
preview_body = {
modules: active_modules,
templates: all_templates,
sites: SettingService.get_sites,
configuration: {
locale: preview_locale,
site: "DE"
}
}
req.send_request(
url: "#{ENV["RENDER_SERVICE_URL"]}/preview",
body: preview_body,
options: {
type: :post,
json: true,
username: ENV["RENDER_SERVICE_BASIC_AUTH_USERNAME"],
password: ENV["RENDER_SERVICE_BASIC_AUTH_PASSWORD"]
}
) do |response_code, response|
return response
end
end
Request is just a thin wrapper
require "uri"
require "net/http"
class Request
# Yields resonse_code (int), response
# Parameters besides url: are optional
def send_request(url:, body: {}, header: {}, options: {})
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
if options.key? :type
case options[:type]
when :get
request = Net::HTTP::Get.new(uri.request_uri, header)
when :post
request = Net::HTTP::Post.new(uri.request_uri, header)
end
else
# Default is GET
request = Net::HTTP::Get.new(uri.request_uri, header)
end
if options.key?(:username) && options.key?(:password)
request.basic_auth options[:username], options[:password]
end
unless body.class == String
body = body.to_json.to_s
end
request.body = body unless body.empty?
puts request.body
# SSL is default
if options.key? :ssl
http.use_ssl = options[:ssl]
else
http.use_ssl = Rails.configuration.force_ssl
#http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
if options.key? :json
request.add_field("Content-Type", "application/json")
end
response = http.request(request)
yield response.code.to_i, response
end
end

The following answer is (probably) not the answer you want - but it's the answer you need:
The best way to fix this is to avoid the loop in the request/response logic (where the rails app calls itself through the other app).
Concurrency might help delay the onset of the issue, but the issue will always occur as long as the loop exists.
For example, assume you have an 100 requests from clients to the Rails app.
Rails will call the other app and the other app's request will be queued as request number 101.
This can be solved with 100 threads (for example, 10 workers with 10 threads each)...
But what will your app do with 200 client requests?
This cycle is endless, the more clients you have the more concurrency you require before you experience DoS.
The only solution is to avoid the loop to begin with.
Either break it up to 3 apps or (better yet), avoid dependencies between micro services.

Related

RoR application request parameters getting overwritten with random symbol

So I have an application running Ruby 2.7.6 and Rails 6.1. Also using rom and rom-http for API calls. We have suddenly started to see our URLs in the browser, as well as server side API request parameters get overwritten with one of the symbols we use to represent an ID in the code. For example a request that should be https://sampleapi.json?currency=USD&locale=EN will be turned into https://sampleapi.json?currency=%3Asearch_id&locale=EN.
:search_id is a symbol we do use as a request parameter in many other places where we send a unique id related to a user search. But somehow, it has been injecting itself into almost all API calls as the actual value (encoded as %3Asearch_id). Sometimes we see API calls filled with almost every request parameter set this way.
The suspect is that something in activeresource or one of the rom gems has changed at some point and we never accounted for it.
There are some requests that would have &search_id as a request parameter... but with this bug the symbol :search_id itself is getting into the right side of param values.
Does this sound familiar to anyone?
Request handler code that processes data to form the final request:
def call(dataset)
uri = URI(dataset.uri)
uri.path += [dataset.name, dataset.path.presence].compact.join('/') + '.json'
# recaptcha api endpoint urls
Rails.logger.info "api endpoint is #{dataset.name}"
if (dataset.name == '/hotels/rooms' && Settings.enable_recaptcha)
api_path = dataset.name.split('/')
uri = URI(Settings.recaptcha_api_gateway_url)
uri.path += ['/', api_path.last, dataset.path.presence].compact.join('/')
end
if Rails.env == 'test' && dataset.name == '/hotels/rateshopping'
dataset.params[:client_ip] = "74.125.228.110"
end
if dataset.request_method == :get
uri.query = URI.encode_www_form(dataset.params.symbolize_keys.to_a.sort)
else
uri.query = "ip_address=#{$user_ip}"
end
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme.to_s == 'https'
request_klass = Net::HTTP.const_get(ROM::Inflector.classify(dataset.request_method))
Rails.logger.info "dataset.params: #{dataset.params}"
request = request_klass.new(uri.request_uri)
dataset.headers.each_with_object(request) do |(header, value), request|
request[header.to_s] = value
end
if dataset.request_method == :post
request['Content-Type'] = 'application/json; charset=utf-8'
request.body = dataset.params.to_json
end
with_cache(dataset, uri) do
with_logging(dataset, uri) { http.request(request) }
end
end

How do I ensure that I am using the same session for multiple HTTP call?

Let's say that I am calling the following code from inside a loop with a 1-second sleep/delay between each iteration and the URL is an API. How do I make sure that Net::HTTP is using the same API session for all the calls? I know the documentation says Net::HTTP.new will try to reuse the same connection. But how do I verify that? Is there a session ID that I can pull out of Net::HTTP?
request = Net::HTTP::Put.new(url)
url = URI(url)
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request["Accept"] = 'application/json'
request["Content-Type"] = 'application/json'
request["Authorization"] = #auth_key
request["cache-control"] = 'no-cache'
request.body = request_body.to_json if request_body
response = http.request(request)
double check the following against the ruby version you are running on
For one, I don't think there is any session ID from what I can see which would be quite a useful feature. Next, looking at the source code, we see the variable setting in lib/net/http.rb in such methods as:
def do_finish
#started = false
#socket.close if #socket
#socket = nil
end
# Returns true if the HTTP session has been started.
def started?
#started
end
# Finishes the HTTP session and closes the TCP connection.
# Raises IOError if the session has not been started.
def finish
raise IOError, 'HTTP session not yet started' unless started?
do_finish
end
Where do_finish sets the instance variable #socket to nil and #socket is used as a BufferedIO instance to run HTTP requests through
So I would write an override method for the finish method and raise an alert when it calls on do_finish.
Looking through the comments start is the safest bet to use the same session, so you could use a start block and compare the id of the instance variable does not change
Net::HTTP.start(url) do |http|
before = http.instance_variable_get(:#socket)
loop do
instance_var = http.instance_variable_get(:#socket)
break unless before == instance_var
end
end

Why won't Rails.cache store API responses?

I am trying to cache the results of API calls. My application makes multiple calls with the same result and I would like it to use the cache to save time. When I use Rails.cache.fetch the block of code (with the API request) is executed every time even though the keys are the same. I have enabled caching in the development environment using rails dev:cache.
I tried to test in the rails console but my local rails console also won't store any keys in the cache. On heroku, the console works for caching but the application still sends every API request.
I have tried to use both memory and file-based caching locally.
Here is the method in application_record.rb
I am removing the parameters that are not consistent or important for my purpose and then using the parameters and path as the cache key.
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def send_request(params,path)
key = params.except(:token, :start_date, :end_date)
key[:path] = path
puts key.to_s
Rails.cache.fetch(key, expires_in: 5.minutes) do
if (!$token || $token == '') then self.authenticate end
params[:token] = $token
url = URI("https://<api-domain>/"+path+"/?"+params.to_query)
puts '**** sending request *****'
begin
Net::HTTP.start(url.host, url.port, :use_ssl => url.scheme == 'https') do |http|
request = Net::HTTP::Get.new(url)
request["cache-control"] = 'no-cache'
response = http.request(request)
if response.code == "200"
return response
elsif response.code[0] == "4"
puts "recursive = "+response.code
puts response.read_body
$token = nil
send_request(params,path)
else
puts "request_report server error"
puts response.code
puts JSON.parse(response.read_body)
response
end
end
rescue
response = "SocketError: check that the server allows outbound https and that the bucksense API is live"
end
end
end
The log shows that requests are made every time. The first and second requests for both of these campaigns are exactly the same.
Entering new campaign: Show********************************
{:metrics=>"wins,win_rate,ctr,clicks_global", :timezone=>"UTC", :type=>"1", :method=>"getdata", :groupby=>"P1D", :dimensions=>"advertiser_name,campaign_name", :path=>"3.0/report/thirdpart"}
**** sending request *****
{:metrics=>"wins,win_rate,ctr,clicks_global", :timezone=>"UTC", :type=>"1", :method=>"getdata", :groupby=>"P1M", :dimensions=>"advertiser_name,campaign_name", :path=>"3.0/report/thirdpart"}
**** sending request *****
Campaign finished: ********************************
Entering new campaign: ********************************
{:metrics=>"wins,win_rate,ctr,clicks_global", :timezone=>"UTC", :type=>"1", :method=>"getdata", :groupby=>"P1D", :dimensions=>"advertiser_name,campaign_name", :path=>"3.0/report/thirdpart"}
**** sending request *****
{:metrics=>"wins,win_rate,ctr,clicks_global", :timezone=>"UTC", :type=>"1", :method=>"getdata", :groupby=>"P1M", :dimensions=>"advertiser_name,campaign_name", :path=>"3.0/report/thirdpart"}
**** sending request *****
Campaign finished: ********************************
I expect this to make API calls only when the same request has not been made within 5 minutes. Instead, it makes the API call every single time.
Thanks for the help, let me know if I'm making a stupid mistake, or if this is a poor way to achieve my desired results.
HTTP::Response objects are not serializable, so they can't be stored in the cache. (See this Rails comment.)

Threads in rails 4.1.9 (using Jruby 9.0.0.0 preview)

So I have a module that is trying to make use of multiple threads, one per site. It then trying to do a request on each thread. From there I am trying to say:
Create a thread for each site, make a post request to another site, if the response.code that comes back is a 500, wait 15 seconds and try again. Try only 5 times, upon the fifth failure - send an email
I am trying to do this in such a way that the code is easily testable with output having to validate that a thread is created. In other words, my tests only care about the response coming back from the request, I have integration tests that test the actual "wait 15 seconds ... " part.
What I have so far is:
module BlackBird
module PublishToSites
module User
def self.publish_user(params)
if Site.all != nil
threads = []
Site.all.each do |s|
threads << Thread.new do
publish(s, params)
end
end
threads.each { |t| t.join }
else
# Send Email
# - Use No Sites Template
end
end
private
def self.publish(site, params)
response = Typhoeus.post(
s.site_api_url + 'users',
:body => params.to_json,
:headers => {
"Authorization" => "Token #{s.site_api_key}",
'Content-Type' => 'application/json'
}
)
return deal_with_response(response)
end
def self.deal_with_response(response)
if response.code == 200
elsif response.code == 500
# wait and try again after 15 seconds for a total of 5 times.
# should we fail on the 5th try, use the email template: Recieved 500.
end
end
end
end
Because this is running on the JVM I will have no issue with multithreading and things should generally run faster then the MRI, speed is of an essence here.
So How do I, once I reach the response.code == 500 section, actually say, wait 15 seconds, try again for a total of 5 times?
since you do not have this code structured and the Thread.new is not just an implementation detail you will need to pass a counter and the "full" state around to the place where you want to "sleep and retry" e.g. :
def self.publish(site, params, try = 0)
response = Typhoeus.post(
site.site_api_url + 'users',
:body => params.to_json,
:headers => {
"Authorization" => "Token #{site.site_api_key}",
'Content-Type' => 'application/json'
}
)
return deal_with_response(response, site, params, try)
end
def self.deal_with_response(response, site, params, try = 0)
if response.code == 200
elsif response.code == 500
# wait and try again after 15 seconds for a total of 5 times.
# should we fail on the 5th try, use the email template: Recieved 500.
if ( try += 1 ) < 5
sleep(15)
publish(site, params, try)
else
send_email "received response 500 for 5 times"
end
end
end
be aware since you're joining on the created threads that the requests will wait potentially ~ 5x15 seconds before returning a response ... when there are 500 failures!

Work with two separate redis instances with sidekiq?

Good afternoon,
I have two separate, but related apps. They should both have their own background queues (read: separate Sidekiq & Redis processes). However, I'd like to occasionally be able to push jobs onto app2's queue from app1.
From a simple queue/push perspective, it would be easy to do this if app1 did not have an existing Sidekiq/Redis stack:
# In a process, far far away
# Configure client
Sidekiq.configure_client do |config|
config.redis = { :url => 'redis://redis.example.com:7372/12', :namespace => 'mynamespace' }
end
# Push jobs without class definition
Sidekiq::Client.push('class' => 'Example::Workers::Trace', 'args' => ['hello!'])
# Push jobs overriding default's
Sidekiq::Client.push('queue' => 'example', 'retry' => 3, 'class' => 'Example::Workers::Trace', 'args' => ['hello!'])
However given that I would already have called a Sidekiq.configure_client and Sidekiq.configure_server from app1, there's probably a step in between here where something needs to happen.
Obviously I could just take the serialization and normalization code straight from inside Sidekiq and manually push onto app2's redis queue, but that seems like a brittle solution. I'd like to be able to use the Client.push functionality.
I suppose my ideal solution would be someting like:
SidekiqTWO.configure_client { remote connection..... }
SidekiqTWO::Client.push(job....)
Or even:
$redis_remote = remote_connection.....
Sidekiq::Client.push(job, $redis_remote)
Obviously a bit facetious, but that's my ideal use case.
Thanks!
So one thing is that According to the FAQ, "The Sidekiq message format is quite simple and stable: it's just a Hash in JSON format." Emphasis mine-- I don't think sending JSON to sidekiq is too brittle to do. Especially when you want fine-grained control around which Redis instance you send the jobs to, as in the OP's situation, I'd probably just write a little wrapper that would let me indicate a Redis instance along with the job being enqueued.
For Kevin Bedell's more general situation to round-robin jobs into Redis instances, I'd imagine you don't want to have the control of which Redis instance is used-- you just want to enqueue and have the distribution be managed automatically. It looks like only one person has requested this so far, and they came up with a solution that uses Redis::Distributed:
datastore_config = YAML.load(ERB.new(File.read(File.join(Rails.root, "config", "redis.yml"))).result)
datastore_config = datastore_config["defaults"].merge(datastore_config[::Rails.env])
if datastore_config[:host].is_a?(Array)
if datastore_config[:host].length == 1
datastore_config[:host] = datastore_config[:host].first
else
datastore_config = datastore_config[:host].map do |host|
host_has_port = host =~ /:\d+\z/
if host_has_port
"redis://#{host}/#{datastore_config[:db] || 0}"
else
"redis://#{host}:#{datastore_config[:port] || 6379}/#{datastore_config[:db] || 0}"
end
end
end
end
Sidekiq.configure_server do |config|
config.redis = ::ConnectionPool.new(:size => Sidekiq.options[:concurrency] + 2, :timeout => 2) do
redis = if datastore_config.is_a? Array
Redis::Distributed.new(datastore_config)
else
Redis.new(datastore_config)
end
Redis::Namespace.new('resque', :redis => redis)
end
end
Another thing to consider in your quest to get high-availability and fail-over is to get Sidekiq Pro which includes reliability features: "The Sidekiq Pro client can withstand transient Redis outages. It will enqueue jobs locally upon error and attempt to deliver those jobs once connectivity is restored." Since sidekiq is for background processes anyway, a short delay if a Redis instance goes down should not affect your application. If one of your two Redis instances goes down and you're using round robin, you've still lost some jobs unless you're using this feature.
As carols10cents says its pretty simple but as I always like to encapsulate the capability and be able to reuse it in other projects I updated an idea from a blog from Hotel Tonight. This following solution improves upon Hotel Tonight's that does not survive Rails 4.1 & Spring preloader.
Currently I make do with adding the following files to lib/remote_sidekiq/:
remote_sidekiq.rb
class RemoteSidekiq
class_attribute :redis_pool
end
remote_sidekiq_worker.rb
require 'sidekiq'
require 'sidekiq/client'
module RemoteSidekiqWorker
def client
pool = RemoteSidekiq.redis_pool || Thread.current[:sidekiq_via_pool] || Sidekiq.redis_pool
Sidekiq::Client.new(pool)
end
def push(worker_name, attrs = [], queue_name = "default")
client.push('args' => attrs, 'class' => worker_name, 'queue' => queue_name)
end
end
You need to create a initializer that sets redis_pool
config/initializers/remote_sidekiq.rb
url = ENV.fetch("REDISCLOUD_URL")
namespace = 'primary'
redis = Redis::Namespace.new(namespace, redis: Redis.new(url: url))
RemoteSidekiq.redis_pool = ConnectionPool.new(size: ENV['MAX_THREADS'] || 6) { redis }
EDIT by Aleks:
In never versions of sidekiq, instead of lines:
redis = Redis::Namespace.new(namespace, redis: Redis.new(url: url))
RemoteSidekiq.redis_pool = ConnectionPool.new(size: ENV['MAX_THREADS'] || 6) { redis }
use lines:
redis_remote_options = {
namespace: "yournamespace",
url: ENV.fetch("REDISCLOUD_URL")
}
RemoteSidekiq.redis_pool = Sidekiq::RedisConnection.create(redis_remote_options)
You can then simply the include RemoteSidekiqWorker module wherever you want. Job done!
**** FOR MORE LARGER ENVIRONMENTS ****
Adding in RemoteWorker Models adds extra benefits:
You can reuse the RemoteWorkers everywhere including the system that has access to the target sidekiq workers. This is transparent to the caller. To use the "RemoteWorkers" form within the target sidekiq system simply do not use an initializer as it will default to using the local Sidekiq client.
Using RemoteWorkers ensure correct arguments are always sent in (the code = documentation)
Scaling up by creating more complicated Sidekiq architectures is transparent to the caller.
Here is an example RemoteWorker
class RemoteTraceWorker
include RemoteSidekiqWorker
include ActiveModel::Model
attr_accessor :message
validates :message, presence: true
def perform_async
if valid?
push(worker_name, worker_args)
else
raise ActiveModel::StrictValidationFailed, errors.full_messages
end
end
private
def worker_name
:TraceWorker.to_s
end
def worker_args
[message]
end
end
I came across this and ran into some issues because I'm using ActiveJob, which complicates how messages are read out of the queue.
Building on ARO's answer, you will still need the redis_pool setup:
remote_sidekiq.rb
class RemoteSidekiq
class_attribute :redis_pool
end
config/initializers/remote_sidekiq.rb
url = ENV.fetch("REDISCLOUD_URL")
namespace = 'primary'
redis = Redis::Namespace.new(namespace, redis: Redis.new(url: url))
RemoteSidekiq.redis_pool = ConnectionPool.new(size: ENV['MAX_THREADS'] || 6) { redis }
Now instead of the worker we'll create an ActiveJob Adapter to queue the request:
lib/active_job/queue_adapters/remote_sidekiq_adapter.rb
require 'sidekiq'
module ActiveJob
module QueueAdapters
class RemoteSidekiqAdapter
def enqueue(job)
#Sidekiq::Client does not support symbols as keys
job.provider_job_id = client.push \
"class" => ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper,
"wrapped" => job.class.to_s,
"queue" => job.queue_name,
"args" => [ job.serialize ]
end
def enqueue_at(job, timestamp)
job.provider_job_id = client.push \
"class" => ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper,
"wrapped" => job.class.to_s,
"queue" => job.queue_name,
"args" => [ job.serialize ],
"at" => timestamp
end
def client
#client ||= ::Sidekiq::Client.new(RemoteSidekiq.redis_pool)
end
end
end
end
I can use the adapter to queue the events now:
require 'active_job/queue_adapters/remote_sidekiq_adapter'
class RemoteJob < ActiveJob::Base
self.queue_adapter = :remote_sidekiq
queue_as :default
def perform(_event_name, _data)
fail "
This job should not run here; intended to hook into
ActiveJob and run in another system
"
end
end
I can now queue the job using the normal ActiveJob api. Whatever app reads this out of the queue will need to have a matching RemoteJob available to perform the action.

Resources