Skip to content

Commit

Permalink
Run specs in a transaction (#780)
Browse files Browse the repository at this point in the history
This replaces truncating the database before every spec.
In avram we're seeing specs run ~20x faster but Lucky apps might expect to see speed ups of around 4-5x.
  • Loading branch information
matthewmcgarvey authored Jan 7, 2022
1 parent 9b184d4 commit 36e2f87
Show file tree
Hide file tree
Showing 14 changed files with 157 additions and 41 deletions.
2 changes: 1 addition & 1 deletion db/migrations/20190723233131_test_change_type.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class TestChangeType::V20190723233131 < Avram::Migrator::Migration::V1
change_type id : Int64
end

TempUserInt64::BaseQuery.first # should not raise
TempUserInt64::BaseQuery.first.delete # should not raise
end

def rollback
Expand Down
2 changes: 1 addition & 1 deletion spec/avram/database_spec.cr
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require "../spec_helper"

describe Avram::Database do
describe "listen" do
describe "listen", tags: Avram::SpecHelper::TRUNCATE do
it "yields the payload from a notify" do
done = Channel(Nil).new
TestDatabase.listen("dinner_time") do |notification|
Expand Down
3 changes: 2 additions & 1 deletion spec/avram/instrumentation_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ require "../spec_helper"

describe "Instrumentation" do
it "publishes the query and args" do
TestDatabase.query "SELECT * FROM users"
# using block form to make sure the ResultSet is closed
TestDatabase.query "SELECT * FROM users" { }

event = Avram::Events::QueryEvent.logged_events.last
event.query.should eq("SELECT * FROM users")
Expand Down
1 change: 0 additions & 1 deletion spec/avram/model_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ describe Avram::Model do
user = UserFactory.create
post = PostFactory.create

user.id.should eq(post.id)
user.should_not eq(post)
end

Expand Down
2 changes: 1 addition & 1 deletion spec/avram/query_logging_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe "Query logging" do
it "does not log truncate statements" do
Avram::QueryLog.dexter.temp_config do |log_io|
TestDatabase.truncate
log_io.to_s.should eq("")
log_io.to_s.should_not contain("TRUNCATE TABLE")
end
end

Expand Down
3 changes: 2 additions & 1 deletion spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ Db::Create.new(quiet: true).run_task
Db::Migrate.new(quiet: true).run_task
Db::VerifyConnection.new(quiet: true).run_task

Avram::SpecHelper.use_transactional_specs(TestDatabase)

Spec.before_each do
TestDatabase.truncate
# All specs seem to run on the same Fiber,
# so we set back to NullStore before each spec
# to ensure queries aren't randomly cached
Expand Down
1 change: 1 addition & 0 deletions src/avram.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require "db"
require "pg"
require "uuid"

require "./ext/db/*"
require "./avram/object_extensions"
require "./avram/criteria"
require "./avram/type"
Expand Down
44 changes: 33 additions & 11 deletions src/avram/database.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ abstract class Avram::Database

@@db : DB::Database? = nil
@@lock = Mutex.new
class_getter transactions = {} of FiberId => DB::Transaction
class_getter connections = {} of FiberId => DB::Connection
class_property lock_id : UInt64?

macro inherited
Habitat.create do
Expand All @@ -24,6 +25,16 @@ abstract class Avram::Database
%}
end

def self.setup_connection(&block : DB::Connection -> Nil)
new.db.setup_connection do |conn|
block.call conn
end
end

def self.verify_connection
new.connection.open.close
end

# Rollback the current transaction
def self.rollback
new.rollback
Expand Down Expand Up @@ -142,28 +153,36 @@ abstract class Avram::Database

# :nodoc:
def run
yield current_transaction.try(&.connection) || db
yield current_connection || db
end

# :nodoc:
def listen(*channels : String, &block : PQ::Notification ->) : Nil
connection.connect_listen(*channels, &block)
end

private def connection : Avram::Connection
protected def connection : Avram::Connection
Avram::Connection.new(url, database_class: self.class)
end

private def db : DB::Database
protected def db : DB::Database
@@db ||= @@lock.synchronize do
# check @@db again because a previous request could have set it after
# the first time it was checked
@@db || connection.open
end
end

private def current_connection : DB::Connection
connections[object_id] ||= db.checkout
end

private def object_id : UInt64
self.class.lock_id || Fiber.current.object_id
end

private def current_transaction : DB::Transaction?
transactions[Fiber.current.object_id]?
current_connection._avram_stack.last?
end

protected def truncate
Expand All @@ -180,7 +199,7 @@ abstract class Avram::Database

# :nodoc:
def transaction : Bool
if current_transaction
if current_transaction.try(&._avram_joinable?)
yield
true
else
Expand All @@ -190,20 +209,23 @@ abstract class Avram::Database
end
end

private def transactions
self.class.transactions
private def connections
self.class.connections
end

private def wrap_in_transaction
db.transaction do |tx|
transactions[Fiber.current.object_id] ||= tx
(current_transaction || current_connection).transaction do
yield
end
true
rescue e : Avram::Rollback
false
ensure
transactions.delete(Fiber.current.object_id)
# TODO: not sure of this
if current_connection._avram_in_transaction?
current_connection.release
connections.delete(object_id)
end
end

class DatabaseCleaner
Expand Down
27 changes: 13 additions & 14 deletions src/avram/migrator/migration.cr
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,17 @@ abstract class Avram::Migrator::Migration::V1
end

def migrated?
DB.open(Avram::Migrator::Runner.database_url) do |db|
db.query_one? "SELECT id FROM migrations WHERE version = $1", version, as: MigrationId
end
Avram.settings
.database_to_migrate
.query_one? "SELECT id FROM migrations WHERE version = $1", version, as: MigrationId
end

private def track_migration(tx : DB::Transaction)
tx.connection.exec "INSERT INTO migrations(version) VALUES ($1)", version
private def track_migration(db : Avram::Database.class)
db.exec "INSERT INTO migrations(version) VALUES ($1)", version
end

private def untrack_migration(tx : DB::Transaction)
tx.connection.exec "DELETE FROM migrations WHERE version = $1", version
private def untrack_migration(db : Avram::Database.class)
db.exec "DELETE FROM migrations WHERE version = $1", version
end

private def execute(statement : String)
Expand All @@ -87,16 +87,15 @@ abstract class Avram::Migrator::Migration::V1
# # Usage
#
# ```
# execute_in_transaction ["DROP TABLE comments;"] do |tx|
# tx.connection.exec "DROP TABLE users;"
# execute_in_transaction ["DROP TABLE comments;"] do |db|
# db.exec "DROP TABLE users;"
# end
# ```
private def execute_in_transaction(statements : Array(String))
DB.open(Avram::Migrator::Runner.database_url) do |db|
db.transaction do |tx|
statements.each { |s| tx.connection.exec s }
yield tx
end
database = Avram.settings.database_to_migrate
database.transaction do
statements.each { |s| database.exec s }
yield database
end
rescue e : PQ::PQError
raise FailedMigration.new(migration: self.class.name, statements: statements, cause: e)
Expand Down
9 changes: 2 additions & 7 deletions src/avram/migrator/runner.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Avram::Migrator::Runner
class_getter migrations = [] of Avram::Migrator::Migration::V1.class

def initialize(@quiet : Bool = false)
Avram::Log.dexter.configure(:none)
end

def self.db_name
Expand All @@ -37,10 +38,6 @@ class Avram::Migrator::Runner
Avram.settings.database_to_migrate.credentials
end

def self.database_url
credentials.url
end

def self.cmd_args
String.build do |args|
args << "-U #{self.db_user}" if self.db_user
Expand Down Expand Up @@ -106,9 +103,7 @@ class Avram::Migrator::Runner
end

def self.setup_migration_tracking_tables
DB.open(database_url) do |db|
db.exec create_table_for_tracking_migrations
end
Avram.settings.database_to_migrate.exec create_table_for_tracking_migrations
end

private def self.create_table_for_tracking_migrations
Expand Down
55 changes: 55 additions & 0 deletions src/avram/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
module Avram::SpecHelper
TRUNCATE = "truncate"

macro use_transactional_specs(*databases)
Spec.around_each do |spec|
Avram::SpecHelper.wrap_spec_in_transaction(spec, {{ databases.splat }})
end
end

def self.wrap_spec_in_transaction(spec : Spec::Example::Procsy, *databases)
if use_truncation?(spec)
spec.run
databases.each(&.truncate)
return
end

tracked_transactions = [] of DB::Transaction

databases.each do |database|
database.lock_id = Fiber.current.object_id
database.connections.values.each do |conn|
tracked_transactions << conn.begin_transaction.tap(&._avram_joinable=(false))
end

database.setup_connection do |conn|
tracked_transactions << conn.begin_transaction.tap(&._avram_joinable=(false))
end
end

spec.run

tracked_transactions.each do |transaction|
next if transaction.closed? || transaction.connection.closed?

transaction.rollback
transaction.connection.release
end
tracked_transactions.clear
databases.each do |database|
database.connections.clear
database.setup_connection { }
end
end

private def self.use_truncation?(spec : Spec::Example::Procsy) : Bool
current = spec.example
while !current.is_a?(Spec::RootContext)
temp = current.as(Spec::Item)
return true if temp.tags.try(&.includes?(TRUNCATE))
current = temp.parent
end

false
end
end
5 changes: 2 additions & 3 deletions src/avram/tasks/db/verify_connection.cr
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ class Db::VerifyConnection < BaseTask
end

def run_task
DB.open(Avram::Migrator::Runner.database_url) do |_db|
end
Avram.settings.database_to_migrate.verify_connection
puts "✔ Connection verified" unless quiet?
rescue PQ::ConnectionError | DB::ConnectionRefused
rescue Avram::ConnectionError
raise <<-ERROR
Unable to connect to Postgres for database '#{Avram.settings.database_to_migrate}'.
Expand Down
11 changes: 11 additions & 0 deletions src/ext/db/connection.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module DB
abstract class Connection
# :nodoc:
getter _avram_stack = [] of DB::Transaction

# :nodoc:
def _avram_in_transaction? : Bool
!_avram_stack.empty?
end
end
end
33 changes: 33 additions & 0 deletions src/ext/db/transaction.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module DB
abstract class Transaction
# :nodoc:
property? _avram_joinable = true
end

class TopLevelTransaction < Transaction
def initialize(@connection : Connection)
@nested_transaction = false
@connection.perform_begin_transaction
@connection._avram_stack.push(self)
end

protected def do_close
@connection.release_from_transaction
@connection._avram_stack.pop
end
end

class SavePointTransaction < Transaction
def initialize(@parent : Transaction, @savepoint_name : String)
@nested_transaction = false
@connection = @parent.connection
@connection.perform_create_savepoint(@savepoint_name)
@connection._avram_stack.push(self)
end

protected def do_close
@parent.release_from_nested_transaction
@connection._avram_stack.pop
end
end
end

0 comments on commit 36e2f87

Please sign in to comment.