Elasticsearch, Chewy, Postgres, and Apartment Multi-Tenancy - ruby-on-rails

I have a multi-tenant rails-api project with rails 4.2.3 and ruby 2.2.2. I found lots of resources out there for dealing with multi-tenancy with rails and postgres, but not much regarding elasticsearch and more specifically the chewy gem. I posted an issue on the chewy gem github page, and I got some good feed back there that helped me eventually find a solution to my problem. I figured that it wouldn't hurt to also post it here for the greater good. Here are the specifics of my question.
I have recently switched from MySQL over to Postgres with multiple schemas, and am having trouble with rake chewy:reset:all. It looks as though it is defaulting to the "public" schema, but I want to specify the schema. I am using the apartment gem, so I put this in one of my indexes:
Apartment::Tenant.switch!('tenant_name')
That fixed the rake problem temporarily, but it got me thinking bigger about elasticsearch and chewy and multi-tenancy in general. Does chewy have any sort of implementation of that? If not, do you have any recommendations?

I created a chewy monkey patch initializer:
# config/initializers/chewy_multi_tenancy.rb
module Chewy
class Index
def self.index_name(suggest = nil)
prefix = Apartment::Tenant.current
if suggest
#index_name = build_index_name(suggest, prefix: prefix)
else
#index_name = build_index_name(
name.sub(/Index\Z/, '').demodulize.underscore,
prefix: prefix
) if name
end
end
#index_name or raise UndefinedIndex
end
end
end
And a custom rake task:
# lib/tasks/elastic.rake
namespace :elastic do
desc "resets all indexes for a given tenant ('rake elastic:reset TENANT=tenant_name')"
task reset: :environment do
if ENV['TENANT'].nil?
puts "Uh oh! You didn't specify a tenant!\n"
puts "Example: rake elastic:reset TENANT=tenant_name"
exit
elsif !Apartment.tenant_names.include?(ENV['TENANT'])
puts "That tenant doesn't exist. Please choose from the following:\n"
puts Apartment.tenant_names
exit
else
Apartment::Tenant.switch!(ENV['TENANT'])
Rake::Task['chewy:reset:all'].invoke
end
end
end
Since I have a completely separate test cluster we don't need to prefix our indexes with "test", so I redefined prefix with the current tenant name. As far as I can tell right now, chewy hits the index_name method every time a specific index is called. It then grabs the correct users index for the current tenant.

Thanks to Eli, got me in the right direction. I have updated Eli's code with latest chewy code.
# config/initializers/chewy_multi_tenancy.rb
module Chewy
class Index
def self.index_name(suggest = nil, prefix: nil, suffix: nil)
tenant_prefix = [Apartment::Tenant.current, prefix]
if suggest
#base_name = (tenant_prefix + [suggest.to_s.presence]).reject(&:blank?).join('_')
else
(tenant_prefix + [ base_name, suffix ]).reject(&:blank?).join('_')
end
end
end
end
and custom rake task:
# lib/tasks/elastic.rake
namespace :elastic do
desc "resets all indexes for a given tenant ('rake elastic:reset TENANT=tenant_name')"
task reset: :environment do
if ENV['TENANT'].nil?
puts "Uh oh! You didn't specify a tenant!\n"
puts "Example: rake elastic:reset TENANT=tenant_name"
exit
elsif !Apartment.tenant_names.include?(ENV['TENANT'])
puts "That tenant doesn't exist. Please choose from the following:\n"
puts Apartment.tenant_names
exit
else
Apartment::Tenant.switch!(ENV['TENANT'])
Rake::Task['chewy:reset'].invoke
end
end
end

Related

Modifying the Rails Object for the seed process

Is it possible to modify the Rails obj?
I only want to modify it briefly and change it back.
My Reasoning:
I am trying to work on my seeds file and make it a little more robust.
In my model there is a process that looks at the current controller and the current user, it tracks this user during there session.
It throws an error though during my seed tests because there is no controller based user session.
What I wanted to do was to add
Rails.seed = true
at the start of my seed, it would get to the model and in the model I would wrap a control flow(if statement) for this property around the block that setups up tracking.
Then I would remove
Rails.seed = true
at the end of the seed file.
Instead of putting it directly on the Rails object, you can use custom configuration
config/initializers/custom_config.rb (name unimportant, just in an initializer)
Rails.configuration.seeding = false
db/seeds.rb
Rails.configuration.seeding = true
User.create
app/models/user.rb
class User < ApplicationRecord
# just as an example
after_initialize do
if Rails.configuration.seeding
puts "Seeding DB"
else
puts "Normal"
end
end
end
output
$ bin/rake db:seed
# Running via Spring preloader in process 19017
# Seeding DB
$ bin/rails c
User.create
# Normal
# => #<User ...>
I wouldn't necessarily recommend modifying the Rails class but to achieve that you could do something like:
class Rails
attr_accessor :seeding
def seeding=(bool)
#seeding = bool
end
def seeding?
#seeding ||= false
end
end
Then you could use Rails.seeding = true to set it and Rails.seeding? to access it. Also it will default to false if it is unset.
Another solution might be wrapping the part that is blowing up in a being rescue block to catch the error.

Creating New Tenant Apartment Gem - Always restart?

I have a rails 4.2 multi-tenant app using the Apartment gem which has been awesome.
Each company has their own subdomain. I'm using a custom "elevator" which looks at the full request host to determine which "Tenant" should be loaded.
When I create a new company I have an after_create hook to create the new tenant with the proper request host.
This always seems to require a restart of the server both in development and production otherwise I get a Tenant Not Found error.
It's using sqlite in development and postgres in production.
Do I really have to restart the server each time I create a new tenant? Is there an automated way to do this? Maybe just reloading the initializer will work, but I'm not sure how to do that/if that's possible?
I have been messing around with this for a month and haven't been able to find a solution that works. Please help!
initializers/apartment.rb
require 'apartment/elevators/host_hash'
config.tenant_names = lambda { Company.pluck :request_host }
Rails.application.config.middleware.use 'Apartment::Elevators::HostHash', Company.full_hosts_hash
initializers/host_hash.rb
require 'apartment/elevators/generic'
module Apartment
module Elevators
class HostHash < Generic
def initialize(app, hash = {}, processor = nil)
super app, processor
#hash = hash
end
def parse_tenant_name(request)
if request.host.split('.').first == "www"
nil
else
raise TenantNotFound,
"Cannot find tenant for host #{request.host}" unless #hash.has_key?(request.host)
#hash[request.host]
end
end
end
end
end
Company Model
after_create :create_tenant
def self.full_hosts_hash
Company.all.inject(Hash.new) do |hash, company|
hash[company.request_host] = company.request_host
hash
end
end
private
def create_tenant
Apartment::Tenant.create(request_host)
end
What ended up working
I changed the elevator configuration to get away from the HostHash one that's in the apartment gem and used a completely custom one. Mostly based off of an issue on the apartment gem github: https://github.com/influitive/apartment/issues/280
initializers/apartment.rb
Rails.application.config.middleware.use 'BaseSite::BaseElevator'
app/middleware/base_site.rb
require 'apartment/elevators/generic'
module BaseSite
class BaseElevator < Apartment::Elevators::Generic
def parse_tenant_name(request)
company = Company.find_by_request_host(request.host)
return company.request_host unless company.nil?
fail StandardError, "No website found at #{request.host} not found"
end
end
end
I think the problem could be that your host_hash.rb lives in the initializers directory. Shouldn't it be in a folder called "middleware"?, as per the Apartment gem ReadME you referenced in your comment. In that example they used app/middleware/my_custom_elevator.rb. Perhaps yours might look like app/middleware/host_hash.rb?
Right now the file is in initializers, so it's loading from there. But your apartment.rb references it by Rails.application.config.middleware.use. Just a hunch but in addition to loading it initially, it may be looking for it in a nonexistent middleware folder. I'd go ahead and create app/middleware, put the file in there instead, and see what happens. Not sure but you might need to alter require paths too.
Let us know if that helps.

How to avoid ActionMailer::Preview committing data to development database?

I'm using Rails 4.1.0.beta1's new Action Mailer previews and have the following code:
class EventInvitationPreview < ActionMailer::Preview
def invitation_email
invite = FactoryGirl.create :event_invitation, :for_match, :from_user, :to_user
EventInvitationMailer.invitation_email(invite)
end
end
This is all good until I actually try to preview my email and get an error saying that validation on a User object failed due to duplicate email addresses. Turns out that ActionMailer::Preview is writing to my development database.
While I could work around the validation failure or use fixtures instead of factories, is there any way to avoid ActionMailer::Preview writing to the development database, e.g. use the test database instead? Or am I just doing it wrong?
Cleaner/Easier (based on other answers) and tested with Rails 7: Do not change Rails' classes but create your own. Id addition to not change the controller but the call method of ActionMailer::Preview.
# app/mailers/preview_mailer.rb
class PreviewMailer < ActionMailer::Preview
def self.call(...)
message = nil
ActiveRecord::Base.transaction do
message = super(...)
raise ActiveRecord::Rollback
end
message
end
end
# inherit from `PreviewController` for your previews
class EventInvitationPreview < PreviewController
def invitation_email
...
end
end
OLD:
You can simply use a transaction around email previews, just put this inside your lib/monkey_mailers_controller.rb (and require it):
# lib/monkey_mailers_controller.rb
class Rails::MailersController
alias_method :preview_orig, :preview
def preview
ActiveRecord::Base.transaction do
preview_orig
raise ActiveRecord::Rollback
end
end
end
Then you can call .create etc. in your mailer previews but nothing will be saved to database. Works in Rails 4.2.3.
A cleaner way to proceed is to prepend a module overriding and wrapping preview into a transaction:
module RollbackingAfterPreview
def preview
ActiveRecord::Base.transaction do
super
raise ActiveRecord::Rollback
end
end
end
Rails.application.config.to_prepare do
class Rails::MailersController
prepend RollbackingAfterPreview
end
end
TL;DR -- The original author of the ActionMailer preview feature (via the MailView gem) provides three examples of different supported approaches:
Pull data from existing fixtures: Account.first
Factory-like pattern: user = User.create! followed by user.destroy
Stub-like: Struct.new(:email, :name).new('name#example.com', 'Jill Smith')
~ ~ ~ ~ ~ ~ ~ ~ ~ ~
To elaborate on the challenge faced by the OP...
Another manifestation of this challenge is attempting to use FactoryGirl.build (rather than create) to generate non-persistent data. This approach is suggested by one of the top Google results for "Rails 4.1" -- http://brewhouse.io/blog/2013/12/17/whats-new-in-rails-4-1.html?brewPubStart=1 -- in the "how to use this new feature" example. This approach seems reasonable, however if you're attempting to generate a url based on that data, it leads to an error along the lines of:
ActionController::UrlGenerationError in Rails::Mailers#preview
No route matches {:action=>"edit", :controller=>"password_resets", :format=>nil, :id=>nil} missing required keys: [:id]
Using FactoryGirl.create (rather than build) would solve this problem, but as the OP notes, leads to polluting the development database.
If you check out the docs for the original MailView gem which became this Rails 4.1 feature, the original author provides a bit more clarity about his intentions in this situation. Namely, the original author provides the following three examples, all focused on data reuse / cleanup / non-persistence, rather than providing a means of using a different database:
# app/mailers/mail_preview.rb or lib/mail_preview.rb
class MailPreview < MailView
# Pull data from existing fixtures
def invitation
account = Account.first
inviter, invitee = account.users[0, 2]
Notifier.invitation(inviter, invitee)
end
# Factory-like pattern
def welcome
user = User.create!
mail = Notifier.welcome(user)
user.destroy
mail
end
# Stub-like
def forgot_password
user = Struct.new(:email, :name).new('name#example.com', 'Jill Smith')
mail = UserMailer.forgot_password(user)
end
end
For Rails 6:
#Markus' answer worked for me, except that it caused a nasty deprecation-soon-will-be-real error related to how Autoloading has changed in Rails 6:
DEPRECATION WARNING: Initialization autoloaded the constants [many constants seemingly unrelated to what I actually did]
Being able to do this is deprecated. Autoloading during initialization is going to be an error condition in future versions of Rails.
[...]
Well, that's no good!
After more searching, this blog and the docs for
to_prepare helped me come up with this solution, which is just #Markus' answer wrapped in to_prepare. (And also it's in initializer/ instead of lib/.)
# /config/initializers/mailer_previews.rb
---
# Wrap previews in a transaction so they don't create objects.
Rails.application.config.to_prepare do
class Rails::MailersController
alias_method :preview_orig, :preview
def preview
ActiveRecord::Base.transaction do
preview_orig
raise ActiveRecord::Rollback
end
end
end
end
If you have a complicated object hierarchy, you can exploit transactional semantics to rollback the database state, as you would in a test environment (assuming your DB supports transactions). For example:
# spec/mailers/previews/price_change_preview.rb
class PriceChangeMailerPreview < ActionMailer::Preview
#transactional strategy
def price_decrease
User.transaction do
user = FactoryGirl.create(:user, :with_favorited_products) #creates a bunch of nested objects
mail = PriceChange.price_decrease(user, user.favorited_products.first)
raise ActiveRecord::Rollback, "Don't really want these objects committed to the db!"
end
mail
end
end
#spec/factories/user.rb
FactoryGirl.define do
factory :user do
...
trait :with_favorited_products do
after(:create) do |user|
user.favorited_products << create(:product)
user.save!
end
end
end
end
We can't use user.destroy with dependent: :destroy in this case because destroying the associated products normally doesn't make sense (if Amazon removes me as a customer, they don't remove all the products I have favorited from the market).
Note that transactions are supported by previous gem implementations of the preview functionality. Not sure why they aren't supported by ActionMailer::Preview.

Rails tire:import with custom logic

I have a Ruby on rails 3.2 application where I'm trying to use Tire and Elastic Search.
I have a User model that has the following declarations:
include Tire::Model::Search
include Tire::Model::Callbacks
I then carried out an initial import of records into Elastic Search by calling:
rake environment tire:import CLASS=User FORCE=true
Is it possible to customise the import task, such that it skips one user? I have a system user that I would prefer not to be indexed?
First, the Rake task is only a convenience method for the most usual cases, when trying elasticsearch/Tire out, etc. For more complex situations, you should write your own indexing code -- it should be very easy.
Second, if you have certain conditions whether the record is indexed or not, you should do what the README instructs you: don't include Tire::Model::Callbacks and manage the indexing lifecycle yourself, eg with:
after_save do
update_index if state == 'published'
end
I've found a rough solution to my problem and wanted to post something back, just in case someone else comes across this. If anyone has any better suggestions, please let me know.
In the end I wrote a tire task that calls the regular import all and then subsequently deletes the system account from the index.
namespace :tire do
desc 'Create search index on User'
task :index_users => :environment do
ENV['CLASS'] = 'User'
ENV['FORCE'] = 'TRUE'
Rake::Task['tire:import'].invoke
#user = User.find_by_type('System')
User.tire.index.remove #user
end
end

Getting started with the Friendly ORM

I'm following this tutorial: http://friendlyorm.com/
I'm using InstantRails to run MySQL locally. To run Ruby and Rails, I'm using normal Windows installations.
When I run Friendly.create_tables! I only get an empty Array returned: => [] and no tables are created in my 'friendly_development' database.
Author of Friendly here.
You'll have to require all of your models before calling Friendly.create_tables! Otherwise, there's no way for Friendly to know which models exist. In a future revision, I'll automatically preload all your models.
I have a rake task, with help from a guy called Sutto, that will load in all your models and then call Friendly.create_tables! and print out all the tables involved.
namespace :friends do
desc "load in all the models and create the tables"
task :create => :environment do
puts "-----------------------------------------------"
Dir[Rails.root.join("app", "models", "*.rb")].each { |f|File.basename(f, ".rb").classify.constantize }
tables = Friendly.create_tables!
tables.each do |table|
puts "Table '#{table}'"
end
puts "-----------------------------------------------"
end
end
rake friends:create
not much to go on here. My guess is that it can't find your model file that you are creating in the path?

Resources