Skip to content

Commit

Permalink
Add side_by_side mode to update_materialized_view
Browse files Browse the repository at this point in the history
This adds a `side_by_side` kwarg to the `update_materialized_view`
method, which builds the new view alongside the old one and then
atomically swaps them to reduce downtime at the cost of increasing disk
usage. It is plumbed through to migrations as a hash value for the
`materialized` kwarg of `update_view`.
  • Loading branch information
Roguelazer authored and calebhearth committed Nov 20, 2024
1 parent d504b40 commit 1f6a905
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 17 deletions.
51 changes: 47 additions & 4 deletions lib/scenic/adapters/postgres.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ module Adapters
# The methods are documented here for insight into specifics of how Scenic
# integrates with Postgres and the responsibilities of {Adapters}.
class Postgres
MAX_IDENTIFIER_LENGTH = 63

# Creates an instance of the Scenic Postgres adapter.
#
# This is the default adapter for Scenic. Configuring it via
Expand Down Expand Up @@ -155,17 +157,34 @@ def create_materialized_view(name, sql_definition, no_data: false)
# @param no_data [Boolean] Default: false. Set to true to create
# materialized view without running the associated query. You will need
# to perform a non-concurrent refresh to populate with data.
# @param side_by_side [Boolean] Default: false. Set to true to create the
# new version under a different name and atomically swap them, limiting
# the time that a view is inaccessible at the cost of doubling disk usage
#
# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
# in use does not support materialized views.
#
# @return [void]
def update_materialized_view(name, sql_definition, no_data: false)
def update_materialized_view(name, sql_definition, no_data: false, side_by_side: false)
raise_unless_materialized_views_supported

IndexReapplication.new(connection: connection).on(name) do
drop_materialized_view(name)
create_materialized_view(name, sql_definition, no_data: no_data)
if side_by_side
session_id = Time.now.to_i
new_name = generate_name name, "new_#{session_id}"
drop_name = generate_name name, "drop_#{session_id}"
IndexReapplication.new(connection: connection).on_side_by_side(
name, new_name, session_id
) do
create_materialized_view(new_name, sql_definition, no_data: no_data)
end
rename_materialized_view(name, old_name)
rename_materialized_view(new_name, name)
drop_materialized_view(old_name)
else
IndexReapplication.new(connection: connection).on(name) do
drop_materialized_view(name)
create_materialized_view(name, sql_definition, no_data: no_data)
end
end
end

Expand All @@ -183,6 +202,20 @@ def drop_materialized_view(name)
execute "DROP MATERIALIZED VIEW #{quote_table_name(name)};"
end

# Renames a materialized view from {name} to {new_name}
#
# @param name The existing name of the materialized view in the database.
# @param new_name The new name to which it should be renamed
# @raise [MaterializedViewsNotSupportedError] if the version of Postgres
# in use does not support materialized views.
#
# @return [void]
def rename_materialized_view(name, new_name)
raise_unless_materialized_views_supported
execute "ALTER MATERIALIZED VIEW #{quote_table_name(name)} " \
"RENAME TO #{quote_table_name(new_name)};"
end

# Refreshes a materialized view from its SQL schema.
#
# This is typically called from application code via {Scenic.database}.
Expand Down Expand Up @@ -276,6 +309,16 @@ def refresh_dependencies_for(name, concurrently: false)
concurrently: concurrently
)
end

def generate_name(base, suffix)
candidate = "#{base}_#{suffix}"
if candidate.size <= MAX_IDENTIFIER_LENGTH
candidate
else
digest_length = MAX_IDENTIFIER_LENGTH - suffix.size - 1
"#{Digest::SHA256.hexdigest(base)[0...digest_length]}_#{suffix}"
end
end
end
end
end
12 changes: 12 additions & 0 deletions lib/scenic/adapters/postgres/index_reapplication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ def on(name)
indexes.each(&method(:try_index_create))
end

def on_side_by_side(name, new_table_name, temporary_id)
indexes = Indexes.new(connection: connection).on(name)
indexes.each_with_index do |index, i|
old_name = "predrop_index_#{temporary_id}_#{i}"
connection.rename_index(name, index.index_name, old_name)
end
yield
indexes.each do |index|
try_index_create(index.with_other_object_name(new_table_name))
end
end

private

attr_reader :connection, :speaker
Expand Down
4 changes: 3 additions & 1 deletion lib/scenic/adapters/postgres/indexes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def indexes_on(name)
SELECT
t.relname as object_name,
i.relname as index_name,
n.nspname as schema_name,
pg_get_indexdef(d.indexrelid) AS definition
FROM pg_class t
INNER JOIN pg_index d ON t.oid = d.indrelid
Expand All @@ -44,7 +45,8 @@ def index_from_database(result)
Scenic::Index.new(
object_name: result["object_name"],
index_name: result["index_name"],
definition: result["definition"]
definition: result["definition"],
schema_name: result["schema_name"],
)
end
end
Expand Down
32 changes: 31 additions & 1 deletion lib/scenic/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,45 @@ class Index
# "CREATE INDEX index_users_on_email ON users USING btree (email)"
attr_reader :definition

# The schema under which the index is defined
# @return [String]
attr_reader :schema_name

# Returns a new instance of Index
#
# @param object_name [String] The name of the object that has the index
# @param index_name [String] The name of the index
# @param definition [String] The SQL statements that defined the index
def initialize(object_name:, index_name:, definition:)
def initialize(object_name:, index_name:, definition:, schema_name:)
@object_name = object_name
@index_name = index_name
@definition = definition
@schema_name = schema_name
end

# Return a new instance of Index with the definition changed to create
# the index against a different object name.
#
# @param object_name [String] The name of the object that has the index
def with_other_object_name(object_name)
type = if @definition.start_with? "CREATE UNIQUE"
"CREATE UNIQUE INDEX"
else
"CREATE INDEX"
end
old_prefix = "#{type} #{@index_name} ON #{@schema_name}.#{@object_name}"
new_prefix = "#{type} #{@index_name} ON #{@schema_name}.#{object_name}"
unless @definition.start_with? old_prefix
raise "Unhandled index definition: '#{@definition}'"
end
suffix = @definition.slice((old_prefix.size)..(@definition.size))
tweaked_definition = new_prefix + suffix
self.class.new(
object_name: object_name,
index_name: @index_name,
schema_name: @schema_name,
definition: tweaked_definition,
)
end
end
end
30 changes: 21 additions & 9 deletions lib/scenic/statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ module Statements
# @param sql_definition [String] The SQL query for the view schema. An error
# will be raised if `sql_definition` and `version` are both set,
# as they are mutually exclusive.
# @param materialized [Boolean, Hash] Set to true to create a materialized
# view. Set to { no_data: true } to create materialized view without
# loading data. Defaults to false.
# @param materialized [Boolean, Hash] Set to a truthy value to create a
# materialized view. Hash
# @option materialized [Boolean] :no_data (false) Set to true to create
# materialized view without running the associated query. You will need
# to perform a non-concurrent refresh to populate with data.
# @return The database response from executing the create statement.
#
# @example Create from `db/views/searches_v02.sql`
Expand Down Expand Up @@ -40,7 +42,7 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false)
Scenic.database.create_materialized_view(
name,
sql_definition,
no_data: no_data(materialized)
no_data: hash_value_or_boolean(materialized, :no_data),
)
else
Scenic.database.create_view(name, sql_definition)
Expand Down Expand Up @@ -82,7 +84,16 @@ def drop_view(name, revert_to_version: nil, materialized: false)
# `rake db rollback`
# @param materialized [Boolean, Hash] True if updating a materialized view.
# Set to { no_data: true } to update materialized view without loading
# data. Defaults to false.
# data. Set to { side_by_side: true} to update materialized view with
# fewer locks but more disk usage. Defaults to false.
# @param materialized [Boolean, Hash] Set a truthy value if updating a
# materialized view.
# @option materialized [Boolean] :no_data (false) Set to true to create
# materialized view without running the associated query. You will need
# to perform a non-concurrent refresh to populate with data.
# @option materialized [Boolean] :side_by_side (false) Set to true to create
# the new version under a different name and atomically swap them,
# limiting downtime at the cost of doubling disk usage.
# @return The database response from executing the create statement.
#
# @example
Expand All @@ -109,7 +120,8 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil,
Scenic.database.update_materialized_view(
name,
sql_definition,
no_data: no_data(materialized)
no_data: hash_value_or_boolean(materialized, :no_data),
side_by_side: hash_value_or_boolean(materialized, :side_by_side),
)
else
Scenic.database.update_view(name, sql_definition)
Expand Down Expand Up @@ -152,9 +164,9 @@ def definition(name, version)
Scenic::Definition.new(name, version).to_sql
end

def no_data(materialized)
if materialized.is_a?(Hash)
materialized.fetch(:no_data, false)
def hash_value_or_boolean(value, key)
if value.is_a? Hash
value.fetch(key, false)
else
false
end
Expand Down
24 changes: 24 additions & 0 deletions spec/scenic/adapters/postgres_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,30 @@ module Adapters
end
end

describe "#rename_materialized_view" do
it "successfully renames a materialized view" do
adapter = Postgres.new

adapter.create_materialized_view(
"greetings",
"SELECT text 'hi' AS greeting",
)
adapter.rename_materialized_view("greetings", "hellos")

expect(adapter.views.map(&:name)).to include("hellos")
end

it "raises an exception if the version of PostgreSQL is too old" do
connection = double("Connection", supports_materialized_views?: false)
connectable = double("Connectable", connection: connection)
adapter = Postgres.new(connectable)
err = Scenic::Adapters::Postgres::MaterializedViewsNotSupportedError

expect { adapter.rename_materialized_view("greetings", "hellos") }
.to raise_error err
end
end

describe "#refresh_materialized_view" do
it "raises an exception if the version of PostgreSQL is too old" do
connection = double("Connection", supports_materialized_views?: false)
Expand Down
20 changes: 18 additions & 2 deletions spec/scenic/statements_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ module Scenic
connection.update_view(:name, version: 3, materialized: true)

expect(Scenic.database).to have_received(:update_materialized_view)
.with(:name, definition.to_sql, no_data: false)
.with(:name, definition.to_sql, no_data: false, side_by_side: false)
end

it "updates the materialized view in the database with NO DATA" do
Expand All @@ -141,7 +141,23 @@ module Scenic
)

expect(Scenic.database).to have_received(:update_materialized_view)
.with(:name, definition.to_sql, no_data: true)
.with(:name, definition.to_sql, no_data: true, side_by_side: false)
end

it "updates the materialized view with side-by-side mode" do
definition = instance_double("Definition", to_sql: "definition")
allow(Definition).to receive(:new)
.with(:name, 3)
.and_return(definition)

connection.update_view(
:name,
version: 3,
materialized: { side_by_side: true },
)

expect(Scenic.database).to have_received(:update_materialized_view)
.with(:name, definition.to_sql, no_data: false, side_by_side: true)
end

it "raises an error if not supplied a version or sql_defintion" do
Expand Down

0 comments on commit 1f6a905

Please sign in to comment.