Skip to content

Commit

Permalink
Add #route_to_readyset Method to Readyset::ControllerExtension for Re…
Browse files Browse the repository at this point in the history
…plica DB Routing (#28)

## Overview

This pull request introduces the route_to_readyset method into the
Readyset::ControllerExtension module. This method provides the
functionality to route queries in specified controller actions to a
replica database. It basically just changes the context of where it's
running by using
[#connected_to](https://apidock.com/rails/v6.0.0/ActiveRecord/ConnectionHandling/connected_to).
## Changes

>  Added the route_to_readyset method.
> The method accepts a list of actions and options to determine where
and how database routing should be applied.
> It employs the around_action callback in controllers to wrap specified
actions.
> Inside the callback, the ActiveRecord connection is switched to a
replica database.

## Usage

Developers can use route_to_readyset in their controllers to specify
actions that should route their queries to a replica database. The
method accepts action names and additional options like :only and
:except to refine the callback application.
## Documentation

YARD documentation has been added to describe the method's purpose,
parameters, and usage. Work-in-progress. I've basically only used YARD
for ViewComponent docs.

## Notes

- Future enhancements might include decoupling the database role symbol
to allow configuration-driven role specification.
- Improve the spec to account for all the possible parameters
- Allow single-query re-routing

In regards to other features:
- Flagged queries could be tagged during re-routing
- Said queries could be sent to a log/reference file of queries that are
flagged but not yet specified in the "cache migration" file. Maybe be
possible to use this file to auto-fill any areas in the cache migration
like "controller#action -> grabs logged queries from file that came from
this action".
    
## Example

```ruby

class PostsController < ApplicationController
  include Readyset::ControllerExtension
  route_to_readyset :index, only: [:index, :show]
end
```
In this example, queries in the index and show actions of the
PostsController will be routed to the replica database.
  • Loading branch information
helpotters authored Dec 8, 2023
1 parent ce01a82 commit df6b396
Show file tree
Hide file tree
Showing 22 changed files with 305 additions and 70 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
# rspec failure tracking
.rspec_status
Gemfile.lock
spec/internal/tmp/
spec/internal/config/storage.yml
spec/internal/db/*.sqlite
2 changes: 1 addition & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
--format documentation
--color
--require spec_helper
--require rails_helper
9 changes: 9 additions & 0 deletions config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

require 'rubygems'
require 'bundler'

Bundler.require :default, :development

Combustion.initialize! :all
run Combustion::Application
18 changes: 2 additions & 16 deletions lib/readyset.rb
Original file line number Diff line number Diff line change
@@ -1,34 +1,20 @@
# lib/readyset.rb

require 'readyset/configuration'
require 'readyset/default_resolver'
require 'readyset/controller_extension'
require 'readyset/middleware'
require 'readyset/query'
require 'readyset/railtie' if defined?(Rails::Railtie)

require 'active_record'

module Readyset
attr_writer :configuration

def self.configuration
@configuration ||= Configuration.new
end

def self.configure
yield(configuration)
end

def self.current_config
configuration.inspect
end

# Executes a raw SQL query against ReadySet. The query is sanitized prior to being executed.
#
# @param [Array<Object>] *sql_array the SQL array to be executed against ReadySet
# @return [PG::Result]
def self.raw_query(*sql_array)
ActiveRecord::Base.establish_connection(Readyset.configuration.connection_url)
ActiveRecord::Base.establish_connection(Readyset::Configuration.configuration.database_url)
ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array(sql_array))
end
end
44 changes: 21 additions & 23 deletions lib/readyset/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,36 @@

module Readyset
class Configuration
attr_accessor :connection_url, :database_selector, :database_resolver,
:database_resolver_context
attr_accessor :database_url

def initialize
@connection_url = ENV['READYSET_URL'] || default_connection_url
@database_selector = { delay: 2.seconds }
@database_resolver = Readyset::DefaultResolver
@database_resolver_context = nil
@database_url = ENV['READYSET_URL'] || default_connection_url
end

def self.current_config
configuration.inspect
end

def default_connection_url
# Check if Rails is defined and if database configuration is available
if defined?(Rails) && Rails.application && Rails.application.config.database_configuration
# Fetch the environment-specific configuration
config = Rails.application.config.database_configuration[Rails.env]
'postgres://user:password@localhost:5432/readyset'
end

# Fetch the 'primary_replica' details if available, otherwise fallback to default database
replica_config = config['primary_replica'] || config
# @return [Readyset::Configuration] Readyset's current configuration
def self.configuration
@configuration ||= Configuration.new
end

# Construct the URL based on the configuration
adapter = replica_config['adapter']
user = replica_config['username']
password = replica_config['password']
host = replica_config['host']
port = replica_config['port']
db = replica_config['database']
# Set Readyset's configuration
# @param config [Readyset::Configuration]
class << self
attr_writer :configuration
end

"#{adapter}://#{user}:#{password}@#{host}:#{port}/#{db}"
else
# Fallback dummy URL
'postgres://user:password@localhost:5432/readyset'
end
# Modify Readyset's current configuration
# @yieldparam [Readyset::Configuration] config current Readyset config
def self.configure
yield configuration
end
end
end
34 changes: 34 additions & 0 deletions lib/readyset/controller_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Readyset
# Module providing controller extensions for routing ActiveRecord queries to a replica database.
module ControllerExtension
extend ActiveSupport::Concern

prepended do
# Sets up an `around_action` for a specified set of controller actions.
# This method is used to route the specified actions through Readyset,
# allowing ActiveRecord queries within those actions to be handled by a replica database.
#
# @example
# route_to_readyset only: [:index, :show]
# route_to_readyset :index
# route_to_readyset except: :index
# route_to_readyset :show, only: [:index, :show], if: -> { some_condition }
#
# @param args [Array<Symbol, Hash>] A list of actions and/or options dictating when the
# around_action should apply.
# The options can include Rails' standard `:only`, `:except`, and conditionals like `:if`.
# @yield [_controller, action_block] An optional block that will execute around the actions.
# Yields the block from the controller action.
# @yieldparam _controller [ActionController::Base] Param is unused.
# @yieldparam action_block [Proc] The block passed along with the action.
#
def self.route_to_readyset(*args, &block)
around_action(*args, *block) do |_controller, action_block|
Readyset.route do
action_block.call
end
end
end
end
end
end
6 changes: 4 additions & 2 deletions lib/readyset/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

module Readyset
class Railtie < Rails::Railtie
initializer 'readyset.configure_rails_initialization' do |app|
app.middleware.use Readyset::Middleware
initializer 'readyset.action_controller' do
ActiveSupport.on_load(:action_controller) do
prepend Readyset::ControllerExtension
end
end
end
end
3 changes: 3 additions & 0 deletions readyset.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ Gem::Specification.new do |spec|
spec.add_dependency 'activesupport', '>= 6.1'
spec.add_dependency 'rake', '~> 13.0'

spec.add_development_dependency 'combustion', '~> 1.3'
spec.add_development_dependency 'factory_bot', '~> 6.4'
spec.add_development_dependency 'rspec', '~> 3.2'
spec.add_development_dependency 'rspec-rails', '~> 6.0'
spec.add_development_dependency 'rubocop-airbnb'
spec.add_development_dependency 'sqlite3', '~> 1.6'
end
38 changes: 16 additions & 22 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,31 @@
# spec/readyset-rails/configuration_spec.rb

require 'spec_helper'
require_relative './../lib/readyset/configuration.rb'
require 'readyset/configuration'

RSpec.describe Readyset::Configuration do
let(:config) { described_class.new }

describe '#initialize' do
it 'sets default values' do
config = Readyset::Configuration.new

expect(config.connection_url).
to eq(ENV['READYSET_URL'] || 'postgres://user:password@localhost:5432/readyset')
expect(config.database_selector).to eq({ delay: 2.seconds })
expect(config.database_resolver).to eq(Readyset::DefaultResolver)
expect(config.database_resolver_context).to be_nil
context 'when no database_url is specified' do
it 'defaults to dummy url' do
default_url = 'postgres://user:password@localhost:5432/readyset'
expect(config.database_url).to eq default_url
end
end

describe '#connection_url' do
context 'when READYSET_URL is set' do
before { ENV['READYSET_URL'] = 'custom_url' }
after { ENV.delete('READYSET_URL') }

it 'returns the value from the environment variable' do
expect(config.connection_url).to eq('custom_url')
end
context 'when database_url ENV var is specified' do
it 'is used instead of dummy url' do
ENV['READYSET_URL'] = 'postgres://test:password@localhost:5433/readyset'
expect(config.database_url).to eq 'postgres://test:password@localhost:5433/readyset'
end
end

context 'when database_url is specified' do
it 'is used instead of dummy url' do
readyset_url = 'postgres://user_test:password@localhost:5433/readyset'
config.database_url = readyset_url

context 'when READYSET_URL is not set' do
it 'returns the default connection URL' do
expect(config.connection_url).to eq('postgres://user:password@localhost:5432/readyset')
end
expect(config.database_url).to eq readyset_url
end
end
end
103 changes: 103 additions & 0 deletions spec/controller_extension_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# spec/readyset/controller_extension_spec.rb

require 'readyset/controller_extension'

RSpec.describe Readyset::ControllerExtension, type: :controller do
# Global Set-up
controller(ActionController::Base) do
# Main point-of-interest in our fake controller
# This line specifies that these queries will be re-routed
route_to_readyset only: [:index, :show]

def index
@posts = Post.where(active: true)
render plain: 'Index'
end

def show
@post = Post.find(params[:id])
render plain: 'Show'
end

def create
Post.create(params[:post])
render plain: 'Create'
end
end

before do
# Need a way to clean this up
routes.draw do
get 'index' => 'anonymous#index'
get 'show/:id' => 'anonymous#show'
post 'create' => 'anonymous#create'
end

# Our fake queries
stub_const('Post', Class.new)
allow(Post).to receive(:where).and_return([])
allow(Post).to receive(:find).and_return(nil)
allow(Post).to receive(:create)

allow(Readyset).to receive(:route).and_yield
end

describe '#route_to_readyset' do
before do
allow(controller.class).to receive(:around_action)
end

def expect_around_action_called_with(*expected_args, &block)
expect(controller.class).to have_received(:around_action).with(*expected_args, &block)
end

context 'when delegating to around_action' do
it 'delegates arguments unchanged to around_action' do
# Arguments based off of _insert_callbacks
# callbacks
action = :test_action
options_hash = { only: [:index, :show] }

# optional block
test_block = proc { 'test block content' }

controller.class.route_to_readyset(action, options_hash, &test_block)

expect_around_action_called_with(action, options_hash, test_block) do |&block|
expect(block).to eq(test_block)
end
end
end

context 'with a single action' do
it 'passes a single action symbol to around_action' do
controller.class.route_to_readyset :index
expect_around_action_called_with(:index)
end
end

context 'with only option' do
it 'passes :only option with multiple actions to around_action' do
controller.class.route_to_readyset only: [:index, :show]
expect_around_action_called_with(only: [:index, :show])
end
end

context 'with except option' do
it 'passes :except option to around_action' do
controller.class.route_to_readyset except: :index
expect_around_action_called_with(except: :index)
end
end

context 'with multiple options and a block' do
it 'accepts multiple options and a block' do
block_conditional = proc {}

controller.class.route_to_readyset :show, only: [:index, :show], if: block_conditional

expect_around_action_called_with(:show, { only: [:index, :show], if: block_conditional })
end
end
end
end
3 changes: 0 additions & 3 deletions spec/default_resolver_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# default_resolver_spec.rb
# spec/readyset/default_resolver_spec.rb

require 'spec_helper'
require_relative './../lib/readyset/default_resolver'
Expand All @@ -11,7 +10,5 @@
it 'returns true by default' do
expect(resolver.read_from_replica?(nil)).to be true
end

# Add more tests based on the logic you implement.
end
end
Empty file.
3 changes: 3 additions & 0 deletions spec/internal/config/database.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
test:
adapter: sqlite3
database: db/combustion_test.sqlite
5 changes: 5 additions & 0 deletions spec/internal/config/routes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

Rails.application.routes.draw do
# Add your own routes here, or remove this file if you don't have need for it.
end
6 changes: 6 additions & 0 deletions spec/internal/db/schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

ActiveRecord::Schema.define do
# Set up any tables you need to exist for your test suite that don't belong
# in migrations.
end
1 change: 1 addition & 0 deletions spec/internal/log/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.log
Empty file.
3 changes: 1 addition & 2 deletions spec/middleware_spec.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# middleware_spec.rb
# spec/readyset/middleware_spec.rb

require 'spec_helper'
require_relative './../lib/readyset/middleware'

RSpec.describe Readyset::Middleware do
RSpec.xdescribe Readyset::Middleware do
let(:app) { double('App', call: true) }
let(:env) { {} }
let(:middleware) { Readyset::Middleware.new(app) }
Expand Down
Loading

0 comments on commit df6b396

Please sign in to comment.