mirror of
https://gitea.invidious.io/iv-org/shard-crystal-db.git
synced 2024-08-15 00:53:32 +00:00
Simplifications and performance improvements (#200)
* Add pool_concurrency_test manual spec Add MT connection count without Mutex * Drop checkout_some, simpler pool_prepared statement * Make pool statement a struct * Drop StringKeyCache mutex The StringKeyCache is now only used inside a connection. It's assumed that connections are not used concurrently with multiple queries. * Drop do_close in pool statements * Add specs and update comment * Fix typo
This commit is contained in:
parent
06df272740
commit
c106775ea9
10 changed files with 139 additions and 149 deletions
|
@ -57,14 +57,6 @@ describe DB::Database do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should close pool statements when closing db" do
|
|
||||||
stmt = uninitialized DB::PoolStatement
|
|
||||||
with_dummy do |db|
|
|
||||||
stmt = db.build("query1")
|
|
||||||
end
|
|
||||||
stmt.closed?.should be_true
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should not reconnect if connection is lost and retry_attempts=0" do
|
it "should not reconnect if connection is lost and retry_attempts=0" do
|
||||||
DummyDriver::DummyConnection.clear_connections
|
DummyDriver::DummyConnection.clear_connections
|
||||||
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=0" do |db|
|
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=0" do |db|
|
||||||
|
@ -187,6 +179,25 @@ describe DB::Database do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "should not checkout multiple connections if there is a statement error" do
|
||||||
|
with_dummy "dummy://localhost:1027?initial_pool_size=1&max_pool_size=10&retry_attempts=10" do |db|
|
||||||
|
expect_raises DB::Error do
|
||||||
|
db.exec("syntax error")
|
||||||
|
end
|
||||||
|
DummyDriver::DummyConnection.connections.size.should eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should attempt all retries if connection is lost" do
|
||||||
|
with_dummy "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=10" do |db|
|
||||||
|
expect_raises DB::PoolRetryAttemptsExceeded do
|
||||||
|
db.exec("raise ConnectionLost")
|
||||||
|
end
|
||||||
|
# 1 initial + 10 retries
|
||||||
|
DummyDriver::DummyConnection.connections.size.should eq(11)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "prepared_statements connection option" do
|
describe "prepared_statements connection option" do
|
||||||
it "defaults to true" do
|
it "defaults to true" do
|
||||||
with_dummy "dummy://localhost:1027" do |db|
|
with_dummy "dummy://localhost:1027" do |db|
|
||||||
|
@ -239,24 +250,6 @@ describe DB::Database do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "prepared_statements_cache connection option" do
|
|
||||||
it "should reuse prepared statements if true" do
|
|
||||||
with_dummy "dummy://localhost:1027?prepared_statements=true&prepared_statements_cache=true" do |db|
|
|
||||||
stmt1 = db.build("the query")
|
|
||||||
stmt2 = db.build("the query")
|
|
||||||
stmt1.object_id.should eq(stmt2.object_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should not reuse prepared statements if false" do
|
|
||||||
with_dummy "dummy://localhost:1027?prepared_statements=true&prepared_statements_cache=false" do |db|
|
|
||||||
stmt1 = db.build("the query")
|
|
||||||
stmt2 = db.build("the query")
|
|
||||||
stmt1.object_id.should_not eq(stmt2.object_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "unprepared statements in pool" do
|
describe "unprepared statements in pool" do
|
||||||
it "creating statements should not create new connections" do
|
it "creating statements should not create new connections" do
|
||||||
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
|
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
require "spec"
|
|
||||||
require "../src/db"
|
require "../src/db"
|
||||||
|
|
||||||
class DummyDriver < DB::Driver
|
class DummyDriver < DB::Driver
|
||||||
|
@ -17,20 +16,37 @@ class DummyDriver < DB::Driver
|
||||||
end
|
end
|
||||||
|
|
||||||
class DummyConnection < DB::Connection
|
class DummyConnection < DB::Connection
|
||||||
|
@@connections = [] of DummyConnection
|
||||||
|
@@connections_count = Atomic(Int32).new(0)
|
||||||
|
|
||||||
def initialize(options : DB::Connection::Options)
|
def initialize(options : DB::Connection::Options)
|
||||||
super(options)
|
super(options)
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
|
@@connections_count.add(1)
|
||||||
@connected = true
|
@connected = true
|
||||||
@@connections ||= [] of DummyConnection
|
{% unless flag?(:preview_mt) %}
|
||||||
@@connections.not_nil! << self
|
# @@connections is only used in single-threaded mode in specs
|
||||||
|
# for benchmarks we want to avoid the overhead of synchronizing this array
|
||||||
|
@@connections << self
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.connections_count
|
||||||
|
@@connections_count.get
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.connections
|
def self.connections
|
||||||
@@connections.not_nil!
|
{% if flag?(:preview_mt) %}
|
||||||
|
raise "DummyConnection.connections is only available in single-threaded mode"
|
||||||
|
{% end %}
|
||||||
|
@@connections
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.clear_connections
|
def self.clear_connections
|
||||||
@@connections.try &.clear
|
{% if flag?(:preview_mt) %}
|
||||||
|
raise "DummyConnection.clear_connections is only available in single-threaded mode"
|
||||||
|
{% end %}
|
||||||
|
@@connections.clear
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_prepared_statement(query) : DB::Statement
|
def build_prepared_statement(query) : DB::Statement
|
||||||
|
@ -117,17 +133,31 @@ class DummyDriver < DB::Driver
|
||||||
end
|
end
|
||||||
|
|
||||||
class DummyStatement < DB::Statement
|
class DummyStatement < DB::Statement
|
||||||
|
@@statements_count = Atomic(Int32).new(0)
|
||||||
|
@@statements_exec_count = Atomic(Int32).new(0)
|
||||||
property params
|
property params
|
||||||
|
|
||||||
def initialize(connection, command : String, @prepared : Bool)
|
def initialize(connection, command : String, @prepared : Bool)
|
||||||
@params = Hash(Int32 | String, DB::Any | Array(DB::Any)).new
|
@params = Hash(Int32 | String, DB::Any | Array(DB::Any)).new
|
||||||
super(connection, command)
|
super(connection, command)
|
||||||
|
@@statements_count.add(1)
|
||||||
raise DB::Error.new(command) if command == "syntax error"
|
raise DB::Error.new(command) if command == "syntax error"
|
||||||
|
raise DB::ConnectionLost.new(connection) if command == "raise ConnectionLost"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.statements_count
|
||||||
|
@@statements_count.get
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.statements_exec_count
|
||||||
|
@@statements_exec_count.get
|
||||||
end
|
end
|
||||||
|
|
||||||
protected def perform_query(args : Enumerable) : DB::ResultSet
|
protected def perform_query(args : Enumerable) : DB::ResultSet
|
||||||
assert_not_closed!
|
assert_not_closed!
|
||||||
|
|
||||||
|
@@statements_exec_count.add(1)
|
||||||
|
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
@connection.as(DummyConnection).check
|
@connection.as(DummyConnection).check
|
||||||
set_params args
|
set_params args
|
||||||
|
@ -137,6 +167,8 @@ class DummyDriver < DB::Driver
|
||||||
protected def perform_exec(args : Enumerable) : DB::ExecResult
|
protected def perform_exec(args : Enumerable) : DB::ExecResult
|
||||||
assert_not_closed!
|
assert_not_closed!
|
||||||
|
|
||||||
|
@@statements_exec_count.add(1)
|
||||||
|
|
||||||
@connection.as(DummyConnection).check
|
@connection.as(DummyConnection).check
|
||||||
set_params args
|
set_params args
|
||||||
raise DB::Error.new("forced exception due to query") if command == "raise"
|
raise DB::Error.new("forced exception due to query") if command == "raise"
|
||||||
|
|
68
spec/manual/pool_concurrency_test.cr
Normal file
68
spec/manual/pool_concurrency_test.cr
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# This file is to be executed as:
|
||||||
|
#
|
||||||
|
# % crystal run --release [-Dpreview_mt] ./spec/manual/pool_concurrency_test.cr -- --options="max_pool_size=5" --duration=30 --concurrency=4
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
require "option_parser"
|
||||||
|
require "../dummy_driver"
|
||||||
|
require "../../src/db"
|
||||||
|
|
||||||
|
options = ""
|
||||||
|
duration = 3
|
||||||
|
concurrency = 4
|
||||||
|
|
||||||
|
OptionParser.parse do |parser|
|
||||||
|
parser.banner = "Usage: pool_concurrency_test [arguments]"
|
||||||
|
parser.on("-o", "--options=VALUE", "Connection string options") { |v| options = v }
|
||||||
|
parser.on("-d", "--duration=SECONDS", "Specifies the duration in seconds") { |v| duration = v.to_i }
|
||||||
|
parser.on("-c", "--concurrency=VALUE", "Specifies the concurrent requests to perform") { |v| concurrency = v.to_i }
|
||||||
|
parser.on("-h", "--help", "Show this help") do
|
||||||
|
puts parser
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
parser.invalid_option do |flag|
|
||||||
|
STDERR.puts "ERROR: #{flag} is not a valid option."
|
||||||
|
STDERR.puts parser
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
multi_threaded = {% if flag?(:preview_mt) %} ENV["CRYSTAL_WORKERS"]?.try(&.to_i?) || 4 {% else %} false {% end %}
|
||||||
|
release = {% if flag?(:release) %} true {% else %} false {% end %}
|
||||||
|
|
||||||
|
if !release
|
||||||
|
puts "WARNING: This should be run in release mode."
|
||||||
|
end
|
||||||
|
|
||||||
|
db = DB.open "dummy://host?#{options}"
|
||||||
|
|
||||||
|
start_time = Time.monotonic
|
||||||
|
|
||||||
|
puts "Starting test for #{duration} seconds..."
|
||||||
|
|
||||||
|
concurrency.times do
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
db.scalar "1"
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
sleep duration.seconds
|
||||||
|
|
||||||
|
end_time = Time.monotonic
|
||||||
|
|
||||||
|
puts " Options : #{options}"
|
||||||
|
puts " Duration (sec) : #{duration} (actual #{end_time - start_time})"
|
||||||
|
puts " Concurrency : #{concurrency}"
|
||||||
|
puts " Multi Threaded : #{multi_threaded ? "Yes (#{multi_threaded})" : "No"}"
|
||||||
|
puts "Total Connections : #{DummyDriver::DummyConnection.connections_count}"
|
||||||
|
puts " Total Statements : #{DummyDriver::DummyStatement.statements_count}"
|
||||||
|
puts " Total Queries : #{DummyDriver::DummyStatement.statements_exec_count}"
|
||||||
|
puts " Throughput (q/s) : #{DummyDriver::DummyStatement.statements_exec_count / duration}"
|
||||||
|
|
||||||
|
if !release
|
||||||
|
puts "WARNING: This should be run in release mode."
|
||||||
|
end
|
|
@ -38,7 +38,6 @@ module DB
|
||||||
@connection_options : Connection::Options
|
@connection_options : Connection::Options
|
||||||
@pool : Pool(Connection)
|
@pool : Pool(Connection)
|
||||||
@setup_connection : Connection -> Nil
|
@setup_connection : Connection -> Nil
|
||||||
@statements_cache = StringKeyCache(PoolPreparedStatement).new
|
|
||||||
|
|
||||||
# Initialize a database with the specified options and connection factory.
|
# Initialize a database with the specified options and connection factory.
|
||||||
# This covers more advanced use cases that might not be supported by an URI connection string such as tunneling connection.
|
# This covers more advanced use cases that might not be supported by an URI connection string such as tunneling connection.
|
||||||
|
@ -81,9 +80,6 @@ module DB
|
||||||
|
|
||||||
# Closes all connection to the database.
|
# Closes all connection to the database.
|
||||||
def close
|
def close
|
||||||
@statements_cache.each_value &.close
|
|
||||||
@statements_cache.clear
|
|
||||||
|
|
||||||
@pool.close
|
@pool.close
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -99,15 +95,6 @@ module DB
|
||||||
|
|
||||||
# :nodoc:
|
# :nodoc:
|
||||||
def fetch_or_build_prepared_statement(query) : PoolStatement
|
def fetch_or_build_prepared_statement(query) : PoolStatement
|
||||||
if @connection_options.prepared_statements_cache
|
|
||||||
@statements_cache.fetch(query) { build_prepared_statement(query) }
|
|
||||||
else
|
|
||||||
build_prepared_statement(query)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# :nodoc:
|
|
||||||
def build_prepared_statement(query) : PoolStatement
|
|
||||||
PoolPreparedStatement.new(self, query)
|
PoolPreparedStatement.new(self, query)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -158,27 +158,6 @@ module DB
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ```
|
|
||||||
# selected, is_candidate = pool.checkout_some(candidates)
|
|
||||||
# ```
|
|
||||||
# `selected` be a resource from the `candidates` list and `is_candidate` == `true`
|
|
||||||
# or `selected` will be a new resource and `is_candidate` == `false`
|
|
||||||
def checkout_some(candidates : Enumerable(WeakRef(T))) : {T, Bool}
|
|
||||||
sync do
|
|
||||||
candidates.each do |ref|
|
|
||||||
resource = ref.value
|
|
||||||
if resource && is_available?(resource)
|
|
||||||
@idle.delete resource
|
|
||||||
resource.before_checkout
|
|
||||||
return {resource, true}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resource = checkout
|
|
||||||
{resource, candidates.any? { |ref| ref.value == resource }}
|
|
||||||
end
|
|
||||||
|
|
||||||
def release(resource : T) : Nil
|
def release(resource : T) : Nil
|
||||||
idle_pushed = false
|
idle_pushed = false
|
||||||
|
|
||||||
|
@ -227,8 +206,6 @@ module DB
|
||||||
# if the connection is lost it will be closed by
|
# if the connection is lost it will be closed by
|
||||||
# the exception to release resources
|
# the exception to release resources
|
||||||
# we still need to remove it from the known pool.
|
# we still need to remove it from the known pool.
|
||||||
# Closed connection will be evicted from statement cache
|
|
||||||
# in PoolPreparedStatement#clean_connections
|
|
||||||
sync { delete(e.resource) }
|
sync { delete(e.resource) }
|
||||||
rescue e : PoolResourceRefused
|
rescue e : PoolResourceRefused
|
||||||
# a ConnectionRefused means a new connection
|
# a ConnectionRefused means a new connection
|
||||||
|
|
|
@ -4,75 +4,20 @@ module DB
|
||||||
# The execution of the statement is retried according to the pool configuration.
|
# The execution of the statement is retried according to the pool configuration.
|
||||||
#
|
#
|
||||||
# See `PoolStatement`
|
# See `PoolStatement`
|
||||||
class PoolPreparedStatement < PoolStatement
|
struct PoolPreparedStatement < PoolStatement
|
||||||
# connections where the statement was prepared
|
|
||||||
@connections = Set(WeakRef(Connection)).new
|
|
||||||
@mutex = Mutex.new
|
|
||||||
|
|
||||||
def initialize(db : Database, query : String)
|
def initialize(db : Database, query : String)
|
||||||
super
|
super
|
||||||
# Prepares a statement on some connection
|
|
||||||
# otherwise the preparation is delayed until the first execution.
|
|
||||||
# After the first initialization the connection must be released
|
|
||||||
# it will be checked out when executing it.
|
|
||||||
|
|
||||||
# This only happens if the db is configured to use prepared statements cache.
|
|
||||||
# Without that there is no reference to the already prepared statement we can
|
|
||||||
# take advantage of.
|
|
||||||
if db.prepared_statements_cache?
|
|
||||||
statement_with_retry &.release_connection
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO use a round-robin selection in the pool so multiple sequentially
|
|
||||||
# initialized statements are assigned to different connections.
|
|
||||||
end
|
|
||||||
|
|
||||||
protected def do_close
|
|
||||||
@mutex.synchronize do
|
|
||||||
# TODO close all statements on all connections.
|
|
||||||
# currently statements are closed when the connection is closed.
|
|
||||||
|
|
||||||
# WHAT-IF the connection is busy? Should each statement be able to
|
|
||||||
# deallocate itself when the connection is free.
|
|
||||||
@connections.clear
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# builds a statement over a real connection
|
# builds a statement over a real connection
|
||||||
# the connection is registered in `@connections`
|
|
||||||
private def build_statement : Statement
|
private def build_statement : Statement
|
||||||
clean_connections
|
conn = @db.pool.checkout
|
||||||
|
|
||||||
conn, existing = @mutex.synchronize do
|
|
||||||
@db.checkout_some(@connections)
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
stmt = conn.prepared.build(@query)
|
conn.prepared.build(@query)
|
||||||
rescue ex
|
rescue ex
|
||||||
conn.release
|
conn.release
|
||||||
raise ex
|
raise ex
|
||||||
end
|
end
|
||||||
if !existing && @db.prepared_statements_cache?
|
|
||||||
@mutex.synchronize do
|
|
||||||
@connections << WeakRef.new(conn)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
stmt
|
|
||||||
end
|
|
||||||
|
|
||||||
private def clean_connections
|
|
||||||
return unless @db.prepared_statements_cache?
|
|
||||||
|
|
||||||
@mutex.synchronize do
|
|
||||||
# remove disposed or closed connections
|
|
||||||
@connections.each do |ref|
|
|
||||||
conn = ref.value
|
|
||||||
if !conn || conn.closed?
|
|
||||||
@connections.delete ref
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@ module DB
|
||||||
# a statement from the DB needs to be able to represent a statement in any
|
# a statement from the DB needs to be able to represent a statement in any
|
||||||
# of the connections of the pool. Otherwise the user will need to deal with
|
# of the connections of the pool. Otherwise the user will need to deal with
|
||||||
# actual connections in some point.
|
# actual connections in some point.
|
||||||
abstract class PoolStatement
|
abstract struct PoolStatement
|
||||||
include StatementMethods
|
include StatementMethods
|
||||||
|
|
||||||
def initialize(@db : Database, @query : String)
|
def initialize(@db : Database, @query : String)
|
||||||
|
|
|
@ -4,15 +4,11 @@ module DB
|
||||||
# The execution of the statement is retried according to the pool configuration.
|
# The execution of the statement is retried according to the pool configuration.
|
||||||
#
|
#
|
||||||
# See `PoolStatement`
|
# See `PoolStatement`
|
||||||
class PoolUnpreparedStatement < PoolStatement
|
struct PoolUnpreparedStatement < PoolStatement
|
||||||
def initialize(db : Database, query : String)
|
def initialize(db : Database, query : String)
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
protected def do_close
|
|
||||||
# unprepared statements do not need to be release in each connection
|
|
||||||
end
|
|
||||||
|
|
||||||
# builds a statement over a real connection
|
# builds a statement over a real connection
|
||||||
private def build_statement : Statement
|
private def build_statement : Statement
|
||||||
conn = @db.pool.checkout
|
conn = @db.pool.checkout
|
||||||
|
|
|
@ -2,11 +2,6 @@ module DB
|
||||||
# Common interface for connection based statements
|
# Common interface for connection based statements
|
||||||
# and for connection pool statements.
|
# and for connection pool statements.
|
||||||
module StatementMethods
|
module StatementMethods
|
||||||
include Disposable
|
|
||||||
|
|
||||||
protected def do_close
|
|
||||||
end
|
|
||||||
|
|
||||||
# See `QueryMethods#scalar`
|
# See `QueryMethods#scalar`
|
||||||
def scalar(*args_, args : Array? = nil)
|
def scalar(*args_, args : Array? = nil)
|
||||||
query(*args_, args: args) do |rs|
|
query(*args_, args: args) do |rs|
|
||||||
|
@ -47,6 +42,10 @@ module DB
|
||||||
# 6. `#do_close` is called to release the statement resources.
|
# 6. `#do_close` is called to release the statement resources.
|
||||||
abstract class Statement
|
abstract class Statement
|
||||||
include StatementMethods
|
include StatementMethods
|
||||||
|
include Disposable
|
||||||
|
|
||||||
|
protected def do_close
|
||||||
|
end
|
||||||
|
|
||||||
# :nodoc:
|
# :nodoc:
|
||||||
getter connection
|
getter connection
|
||||||
|
|
|
@ -1,28 +1,21 @@
|
||||||
module DB
|
module DB
|
||||||
class StringKeyCache(T)
|
class StringKeyCache(T)
|
||||||
@cache = {} of String => T
|
@cache = {} of String => T
|
||||||
@mutex = Mutex.new
|
|
||||||
|
|
||||||
def fetch(key : String) : T
|
def fetch(key : String) : T
|
||||||
@mutex.synchronize do
|
value = @cache.fetch(key, nil)
|
||||||
value = @cache.fetch(key, nil)
|
value = @cache[key] = yield unless value
|
||||||
value = @cache[key] = yield unless value
|
value
|
||||||
value
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def each_value
|
def each_value
|
||||||
@mutex.synchronize do
|
@cache.each do |_, value|
|
||||||
@cache.each do |_, value|
|
yield value
|
||||||
yield value
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear
|
def clear
|
||||||
@mutex.synchronize do
|
@cache.clear
|
||||||
@cache.clear
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue