skaidb 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +55 -16
- data/docs/api.md +44 -10
- data/docs/pooling.md +131 -9
- data/docs/streaming.md +37 -2
- data/docs/types.md +3 -2
- data/lib/skaidb.rb +753 -97
- metadata +2 -2
data/lib/skaidb.rb
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
# Placeholders use the pg-style +$1+, +$2+, ... . They are sent as TYPED values
|
|
26
26
|
# through a server-side prepared statement where the server accepts one, and
|
|
27
27
|
# interpolated into the SQL client-side, with correct quoting, where it does not
|
|
28
|
-
# (old servers, and statement kinds that cannot be prepared).
|
|
28
|
+
# (old servers, and statement kinds that cannot be prepared). +stream+ sends
|
|
29
|
+
# its parameters typed in the stream request itself and never interpolates.
|
|
29
30
|
require "socket"
|
|
30
31
|
require "openssl"
|
|
31
32
|
require "securerandom"
|
|
@@ -34,7 +35,7 @@ require "bigdecimal"
|
|
|
34
35
|
module Skaidb
|
|
35
36
|
# The package version — the single source of truth. The gemspec reads it,
|
|
36
37
|
# and it is what the driver reports to the server in the Hello frame.
|
|
37
|
-
VERSION = "1.
|
|
38
|
+
VERSION = "1.3.0"
|
|
38
39
|
|
|
39
40
|
# Base class for every error raised by this driver.
|
|
40
41
|
class Error < StandardError; end
|
|
@@ -677,11 +678,28 @@ module Skaidb
|
|
|
677
678
|
class Connection
|
|
678
679
|
# @return [Boolean] whether the connection has been closed
|
|
679
680
|
attr_reader :closed
|
|
680
|
-
|
|
681
|
+
# @return [String, nil] the member this connection reached, as the
|
|
682
|
+
# "host:port" it dialed (nil until it connects)
|
|
683
|
+
attr_reader :endpoint
|
|
684
|
+
# Internal, for Pool: the pool told of this connection's round trips,
|
|
685
|
+
# transport failures and writes, and asked for the dial order when it
|
|
686
|
+
# re-dials (nil outside a pool); the member the pool counts it against;
|
|
687
|
+
# the affinity key it was checked out under; whether it is checked out.
|
|
688
|
+
attr_accessor :observer, :pool_member, :pool_affinity, :pool_out
|
|
689
|
+
|
|
690
|
+
# +endpoints+ and +observer+ are internal, for Pool: an explicit dial
|
|
691
|
+
# order of [host, port] pairs (no shuffle) and the pool that owns the
|
|
692
|
+
# connection.
|
|
681
693
|
def initialize(host:, port:, user:, password:, consistency:, timeout:,
|
|
682
694
|
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
683
695
|
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
684
|
-
tls_client_key: nil, auth_mechanism: :scram
|
|
696
|
+
tls_client_key: nil, auth_mechanism: :scram, endpoints: nil,
|
|
697
|
+
observer: nil)
|
|
698
|
+
@observer = observer
|
|
699
|
+
@endpoint = nil
|
|
700
|
+
@pool_member = nil
|
|
701
|
+
@pool_affinity = nil
|
|
702
|
+
@pool_out = false
|
|
685
703
|
@consistency = Consistency.resolve(consistency)
|
|
686
704
|
@auth_mechanism = AuthMechanism.resolve(auth_mechanism)
|
|
687
705
|
if tls_client_cert.to_s.empty? != tls_client_key.to_s.empty?
|
|
@@ -704,7 +722,7 @@ module Skaidb
|
|
|
704
722
|
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
705
723
|
tls_insecure: tls_insecure, tls_server_name: tls_server_name,
|
|
706
724
|
seeds: seeds, tls_client_cert: tls_client_cert,
|
|
707
|
-
tls_client_key: tls_client_key }
|
|
725
|
+
tls_client_key: tls_client_key, endpoints: endpoints }
|
|
708
726
|
dial!
|
|
709
727
|
end
|
|
710
728
|
|
|
@@ -726,11 +744,19 @@ module Skaidb
|
|
|
726
744
|
# Seeds: try each until one connects AND authenticates — a node that
|
|
727
745
|
# accepts TCP while unhealthy must not swallow the attempt. skaidb is
|
|
728
746
|
# leaderless, so any node serves; the order is shuffled so many
|
|
729
|
-
# clients spread instead of stampeding the first entry.
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
747
|
+
# clients spread instead of stampeding the first entry. A pooled
|
|
748
|
+
# connection dials in the pool's order instead: the member the pool
|
|
749
|
+
# picked first, and on a re-dial the usable members with the one it
|
|
750
|
+
# lost last.
|
|
751
|
+
endpoints = if @observer && @endpoint
|
|
752
|
+
@observer.dial_order(@endpoint)
|
|
753
|
+
elsif @dial_args[:endpoints]
|
|
754
|
+
@dial_args[:endpoints]
|
|
755
|
+
else
|
|
756
|
+
(seeds && !seeds.empty? ? seeds : ["#{host}:#{port}"]).map do |sd|
|
|
757
|
+
Skaidb.parse_endpoint(sd, port)
|
|
758
|
+
end.shuffle
|
|
759
|
+
end
|
|
734
760
|
last = nil
|
|
735
761
|
endpoints.each do |(h, prt)|
|
|
736
762
|
begin
|
|
@@ -745,10 +771,12 @@ module Skaidb
|
|
|
745
771
|
else
|
|
746
772
|
handshake(user.to_s, password.to_s)
|
|
747
773
|
end
|
|
774
|
+
@endpoint = "#{h}:#{prt}"
|
|
748
775
|
last = nil
|
|
749
776
|
break
|
|
750
777
|
rescue StandardError => e
|
|
751
778
|
last = e
|
|
779
|
+
@observer.note_failure("#{h}:#{prt}") if @observer && Skaidb.member_fault?(e)
|
|
752
780
|
begin
|
|
753
781
|
@sock&.close
|
|
754
782
|
rescue StandardError
|
|
@@ -848,7 +876,9 @@ module Skaidb
|
|
|
848
876
|
raise QueryError, "statement expects #{nparams} parameters, got #{order.length}"
|
|
849
877
|
end
|
|
850
878
|
|
|
851
|
-
|
|
879
|
+
res = exec_prepared(id, order, level)
|
|
880
|
+
note_statement(sql)
|
|
881
|
+
return res
|
|
852
882
|
end
|
|
853
883
|
end
|
|
854
884
|
text = begin
|
|
@@ -890,12 +920,10 @@ module Skaidb
|
|
|
890
920
|
req << [b.bytesize].pack("V") << b
|
|
891
921
|
end
|
|
892
922
|
end
|
|
893
|
-
reader =
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
end
|
|
898
|
-
parse_response(reader).cmd_tuples
|
|
923
|
+
reader = request(req)
|
|
924
|
+
n = parse_response(reader).cmd_tuples
|
|
925
|
+
note_statement(sql)
|
|
926
|
+
n
|
|
899
927
|
end
|
|
900
928
|
|
|
901
929
|
# Stream a result set: yields one row Hash at a time while holding a
|
|
@@ -903,6 +931,17 @@ module Skaidb
|
|
|
903
931
|
# large scans.
|
|
904
932
|
#
|
|
905
933
|
# conn.stream("SELECT ...") { |row| puts row["id"] }
|
|
934
|
+
# conn.stream("SELECT id, ts FROM events WHERE ts >= $1 AND ts < $2", [from, to]) { |row| ... }
|
|
935
|
+
#
|
|
936
|
+
# +params+ bind +$1+, +$2+, ... exactly as +exec_params+ binds them —
|
|
937
|
+
# typed, the same values accepted (Arrays, Hashes, Times, ...), the same
|
|
938
|
+
# placeholder checks — and travel in OP_QUERY_STREAM_PARAMS, which needs
|
|
939
|
+
# a server >= 0.330: an older one answers QueryError "server does not
|
|
940
|
+
# support parameterised streaming (skaidb >= 0.330): ..." and the
|
|
941
|
+
# connection stays usable. Unlike +exec_params+ there is no client-side
|
|
942
|
+
# text fallback. Without parameters (+nil+ or +[]+) the request is the
|
|
943
|
+
# original OP_QUERY_STREAM with the SQL untouched, so a parameterless
|
|
944
|
+
# stream works against every server.
|
|
906
945
|
#
|
|
907
946
|
# The protocol forbids any other request on the connection until the
|
|
908
947
|
# stream ends, so the whole exchange runs under the connection mutex
|
|
@@ -935,26 +974,43 @@ module Skaidb
|
|
|
935
974
|
# no row arrived) while +rows+ stays empty — the rows went to the block;
|
|
936
975
|
# a statement that produces no rows returns its ordinary +:mutation+ or
|
|
937
976
|
# +:ddl+ Result without yielding.
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
def stream(sql, consistency: nil)
|
|
941
|
-
return enum_for(:stream, sql, consistency: consistency) unless block_given?
|
|
977
|
+
def stream(sql, params = [], consistency: nil)
|
|
978
|
+
return enum_for(:stream, sql, params, consistency: consistency) unless block_given?
|
|
942
979
|
raise ConnectionError, "connection is closed" if @closed
|
|
943
|
-
ensure_live!
|
|
944
980
|
|
|
945
981
|
level = consistency.nil? ? @consistency : Consistency.resolve(consistency)
|
|
946
|
-
|
|
947
|
-
|
|
982
|
+
params ||= []
|
|
983
|
+
unless params.is_a?(Array)
|
|
984
|
+
raise ArgumentError, "stream params must be an Array, got #{params.class} " \
|
|
985
|
+
"(the consistency is a keyword: stream(sql, consistency: :one))"
|
|
986
|
+
end
|
|
987
|
+
# Built before anything touches the socket, so a parameter that cannot
|
|
988
|
+
# be bound is refused without a round trip (or a re-dial).
|
|
989
|
+
req = stream_request(sql, params, level)
|
|
990
|
+
ensure_live!
|
|
991
|
+
|
|
948
992
|
live = false
|
|
949
993
|
summary = nil
|
|
950
994
|
@mutex.synchronize do
|
|
951
|
-
|
|
952
|
-
|
|
995
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
996
|
+
begin
|
|
997
|
+
write_frame(req)
|
|
998
|
+
r = Reader.new(read_frame)
|
|
999
|
+
rescue ConnectionError
|
|
1000
|
+
observe_failure
|
|
1001
|
+
raise
|
|
1002
|
+
end
|
|
1003
|
+
@observer&.note_round_trip(self, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
953
1004
|
tag = r.u8
|
|
1005
|
+
note_statement(sql) unless tag == 3
|
|
954
1006
|
case tag
|
|
955
1007
|
when 3
|
|
956
1008
|
msg = r.text
|
|
957
|
-
|
|
1009
|
+
if msg.include?("unknown opcode")
|
|
1010
|
+
what = params.empty? ? "streaming" : "parameterised streaming (skaidb >= 0.330)"
|
|
1011
|
+
raise QueryError, "server does not support #{what}: #{msg}"
|
|
1012
|
+
end
|
|
1013
|
+
raise QueryError, msg
|
|
958
1014
|
when 1
|
|
959
1015
|
return Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation) # not row-producing
|
|
960
1016
|
when 2
|
|
@@ -1023,11 +1079,7 @@ module Skaidb
|
|
|
1023
1079
|
|
|
1024
1080
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
1025
1081
|
req = [2].pack("C") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
1026
|
-
reader =
|
|
1027
|
-
@mutex.synchronize do
|
|
1028
|
-
write_frame(req)
|
|
1029
|
-
reader = Reader.new(read_frame)
|
|
1030
|
-
end
|
|
1082
|
+
reader = request(req)
|
|
1031
1083
|
tag = reader.u8
|
|
1032
1084
|
case tag
|
|
1033
1085
|
when 4
|
|
@@ -1055,11 +1107,7 @@ module Skaidb
|
|
|
1055
1107
|
v = Skaidb.encode_value(p)
|
|
1056
1108
|
req << [v.bytesize].pack("V") << v
|
|
1057
1109
|
end
|
|
1058
|
-
reader =
|
|
1059
|
-
@mutex.synchronize do
|
|
1060
|
-
write_frame(req)
|
|
1061
|
-
reader = Reader.new(read_frame)
|
|
1062
|
-
end
|
|
1110
|
+
reader = request(req)
|
|
1063
1111
|
parse_response(reader)
|
|
1064
1112
|
end
|
|
1065
1113
|
|
|
@@ -1113,6 +1161,9 @@ module Skaidb
|
|
|
1113
1161
|
rescue StandardError
|
|
1114
1162
|
# ignore — socket already gone
|
|
1115
1163
|
end
|
|
1164
|
+
# Closed while checked out instead of returned: the pool stops
|
|
1165
|
+
# counting it against its member.
|
|
1166
|
+
@observer.note_closed(self) if @observer && @pool_out
|
|
1116
1167
|
nil
|
|
1117
1168
|
end
|
|
1118
1169
|
|
|
@@ -1275,12 +1326,62 @@ module Skaidb
|
|
|
1275
1326
|
|
|
1276
1327
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
1277
1328
|
req = [1, consistency].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
1278
|
-
reader =
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1329
|
+
reader = request(req)
|
|
1330
|
+
res = parse_response(reader)
|
|
1331
|
+
note_statement(sql)
|
|
1332
|
+
res
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
# The request +stream+ sends. Without parameters: OP_QUERY_STREAM with
|
|
1336
|
+
# the SQL as given, byte for byte what every server understands. With
|
|
1337
|
+
# them: OP_QUERY_STREAM_PARAMS (server >= 0.330), $N rewritten to the
|
|
1338
|
+
# positional ? and the values typed in ? order — the parameter vector
|
|
1339
|
+
# OP_EXECUTE carries (u16 count, then u32 length + value each).
|
|
1340
|
+
def stream_request(sql, params, level)
|
|
1341
|
+
if params.empty?
|
|
1342
|
+
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
1343
|
+
return [5, level].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
1282
1344
|
end
|
|
1283
|
-
|
|
1345
|
+
|
|
1346
|
+
qsql, order = Skaidb.to_qmark(sql.to_s, params)
|
|
1347
|
+
# The count is a u16 on the wire, and pack would wrap a larger one.
|
|
1348
|
+
raise QueryError, "a statement takes at most 65535 parameters, got #{order.length}" if order.length > 0xFFFF
|
|
1349
|
+
|
|
1350
|
+
sql_bytes = qsql.dup.force_encoding("UTF-8").b
|
|
1351
|
+
req = +([9, level].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes + [order.length].pack("v"))
|
|
1352
|
+
order.each do |p|
|
|
1353
|
+
b = Skaidb.encode_value(p)
|
|
1354
|
+
req << [b.bytesize].pack("V") << b
|
|
1355
|
+
end
|
|
1356
|
+
req
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
# One request and its response under the connection mutex. In a pool the
|
|
1360
|
+
# round trip (send to the first response frame) feeds the member's
|
|
1361
|
+
# latency average, and a transport failure counts against the member.
|
|
1362
|
+
def request(req)
|
|
1363
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1364
|
+
frame = begin
|
|
1365
|
+
@mutex.synchronize do
|
|
1366
|
+
write_frame(req)
|
|
1367
|
+
read_frame
|
|
1368
|
+
end
|
|
1369
|
+
rescue ConnectionError
|
|
1370
|
+
observe_failure
|
|
1371
|
+
raise
|
|
1372
|
+
end
|
|
1373
|
+
@observer&.note_round_trip(self, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
1374
|
+
Reader.new(frame)
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
def observe_failure
|
|
1378
|
+
@observer.note_failure(@endpoint) if @observer && @endpoint
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
# A statement succeeded: a pool with read-your-writes on remembers which
|
|
1382
|
+
# member took a write.
|
|
1383
|
+
def note_statement(sql)
|
|
1384
|
+
@observer&.note_statement(self, sql)
|
|
1284
1385
|
end
|
|
1285
1386
|
|
|
1286
1387
|
def parse_response(r)
|
|
@@ -1338,23 +1439,8 @@ module Skaidb
|
|
|
1338
1439
|
# is optional and, when given, must equal the certificate's Common Name)
|
|
1339
1440
|
# @yield [conn] optional block; the connection is closed when it returns
|
|
1340
1441
|
# @return [Connection] (or the block's value when a block is given)
|
|
1341
|
-
def self.connect(
|
|
1342
|
-
|
|
1343
|
-
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
1344
|
-
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
1345
|
-
tls_client_key: nil, auth_mechanism: :scram)
|
|
1346
|
-
tls = true if tls_ca || tls_insecure || tls_client_cert
|
|
1347
|
-
# A SCRAM login with no user is "anonymous"; a certificate login with no
|
|
1348
|
-
# user sends an empty one and lets the certificate's CN decide.
|
|
1349
|
-
user = "anonymous" if user.nil? && AuthMechanism.resolve(auth_mechanism) == AuthMechanism::SCRAM
|
|
1350
|
-
conn = Connection.new(host: host, port: port, user: user,
|
|
1351
|
-
password: password, consistency: consistency,
|
|
1352
|
-
timeout: timeout, database: database, tls: tls,
|
|
1353
|
-
tls_ca: tls_ca, tls_insecure: tls_insecure,
|
|
1354
|
-
tls_server_name: tls_server_name, seeds: seeds,
|
|
1355
|
-
tls_client_cert: tls_client_cert,
|
|
1356
|
-
tls_client_key: tls_client_key,
|
|
1357
|
-
auth_mechanism: auth_mechanism)
|
|
1442
|
+
def self.connect(**options)
|
|
1443
|
+
conn = Connection.new(**connection_options(**options))
|
|
1358
1444
|
return conn unless block_given?
|
|
1359
1445
|
|
|
1360
1446
|
begin
|
|
@@ -1363,67 +1449,257 @@ module Skaidb
|
|
|
1363
1449
|
conn.close
|
|
1364
1450
|
end
|
|
1365
1451
|
end
|
|
1366
|
-
|
|
1452
|
+
|
|
1453
|
+
# Internal: Skaidb.connect's keywords with their defaults applied, as
|
|
1454
|
+
# Connection.new takes them (Pool dials every connection with these).
|
|
1455
|
+
def self.connection_options(host: "localhost", port: 7000, user: nil,
|
|
1456
|
+
password: "", consistency: :quorum, timeout: 10.0,
|
|
1457
|
+
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
1458
|
+
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
1459
|
+
tls_client_key: nil, auth_mechanism: :scram)
|
|
1460
|
+
tls = true if tls_ca || tls_insecure || tls_client_cert
|
|
1461
|
+
# A SCRAM login with no user is "anonymous"; a certificate login with no
|
|
1462
|
+
# user sends an empty one and lets the certificate's CN decide.
|
|
1463
|
+
user = "anonymous" if user.nil? && AuthMechanism.resolve(auth_mechanism) == AuthMechanism::SCRAM
|
|
1464
|
+
{ host: host, port: port, user: user, password: password, consistency: consistency,
|
|
1465
|
+
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
1466
|
+
tls_insecure: tls_insecure, tls_server_name: tls_server_name, seeds: seeds,
|
|
1467
|
+
tls_client_cert: tls_client_cert, tls_client_key: tls_client_key,
|
|
1468
|
+
auth_mechanism: auth_mechanism }
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
# Internal: "host:port", or a bare host on +default_port+, as [host, port].
|
|
1472
|
+
def self.parse_endpoint(text, default_port)
|
|
1473
|
+
h, _, p = text.to_s.rpartition(":")
|
|
1474
|
+
h.empty? ? [text.to_s, default_port] : [h, p.to_i]
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# Leading keywords of statements that never write: everything else counts
|
|
1478
|
+
# as a write for read-your-writes affinity (a read taken for a write only
|
|
1479
|
+
# keeps the caller on its member a little longer).
|
|
1480
|
+
READ_KEYWORDS = %w[select show explain describe desc values with use].freeze
|
|
1481
|
+
# Whitespace, comments and opening parentheses before the first keyword.
|
|
1482
|
+
STATEMENT_LEAD = %r{\A(?:\s+|\(|--[^\n]*\n?|/\*.*?(?:\*/|\z))*}m.freeze
|
|
1483
|
+
private_constant :READ_KEYWORDS, :STATEMENT_LEAD
|
|
1484
|
+
|
|
1485
|
+
# Internal: whether +sql+ counts as a write for read-your-writes — its
|
|
1486
|
+
# first keyword, after whitespace, comments and opening parentheses, is
|
|
1487
|
+
# none of SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, VALUES, WITH, USE.
|
|
1488
|
+
def self.write_statement?(sql)
|
|
1489
|
+
rest = sql.to_s.b.sub(STATEMENT_LEAD, "")
|
|
1490
|
+
!READ_KEYWORDS.include?(rest[/\A[A-Za-z_]*/].downcase)
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
# Internal: whether a failed dial says something about the member's
|
|
1494
|
+
# health. A refused login is the caller's problem, not the member's —
|
|
1495
|
+
# except a capacity refusal, which is the member being full.
|
|
1496
|
+
def self.member_fault?(error)
|
|
1497
|
+
return true unless error.is_a?(Error) # socket, TLS, timeout
|
|
1498
|
+
|
|
1499
|
+
msg = error.message
|
|
1500
|
+
return msg.include?("too many connections:") if msg.start_with?("authentication denied")
|
|
1501
|
+
|
|
1502
|
+
!msg.start_with?("server signature mismatch")
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# A thread-safe pool of connections, balanced across the cluster's members.
|
|
1367
1506
|
#
|
|
1368
1507
|
# +maxsize+ bounds the connections kept IDLE, not the number checked out: a
|
|
1369
1508
|
# burst creates extras and the surplus is closed on return. Every keyword
|
|
1370
|
-
# accepted by Skaidb.connect passes through
|
|
1371
|
-
#
|
|
1509
|
+
# accepted by Skaidb.connect passes through: +host+/+port+/+seeds+ name the
|
|
1510
|
+
# starting members, and the rest (TLS, the session database, ...) apply to
|
|
1511
|
+
# every connection.
|
|
1512
|
+
#
|
|
1513
|
+
# Load balancing (PROTOCOL.md §6.1, the same in every skaidb driver; see
|
|
1514
|
+
# docs/pooling.md):
|
|
1515
|
+
#
|
|
1516
|
+
# - +load_balancing+ — +:random+ (default: each new connection dials the
|
|
1517
|
+
# members in random order), +:round_robin+ (checkouts rotate over the
|
|
1518
|
+
# members) or +:least_latency+ (of two members picked at random, the one
|
|
1519
|
+
# with the lower latency average x checked-out connections).
|
|
1520
|
+
# - +idle_order+ — +:lifo+ (default: the most recently returned idle
|
|
1521
|
+
# connection first) or +:fifo+ (the longest idle first, so every idle
|
|
1522
|
+
# connection keeps getting used).
|
|
1523
|
+
# - +discover+ — read the members from SHOW ENDPOINTS (server >= 0.327)
|
|
1524
|
+
# when a member is first reached and every +refresh_interval+ seconds
|
|
1525
|
+
# after (every 5 s while one is not ready): joiners are added, leavers
|
|
1526
|
+
# dropped, and members reported +resyncing+ or +unreachable+ get no new
|
|
1527
|
+
# connections until they are +ready+ again.
|
|
1528
|
+
# - Health ejection: a member with +eject_errors+ consecutive transport
|
|
1529
|
+
# failures, or whose latency average is above +eject_latency_factor+
|
|
1530
|
+
# times the other members' median and more than +eject_latency_floor+
|
|
1531
|
+
# seconds above it, gets no connections for +eject_backoff+ seconds
|
|
1532
|
+
# (doubling per repeat, up to +eject_backoff_max+); then a background
|
|
1533
|
+
# thread probes it and re-admits it. The last usable member is never
|
|
1534
|
+
# ejected.
|
|
1535
|
+
# - +read_your_writes+ — seconds after a write during which checkouts with
|
|
1536
|
+
# the same +affinity:+ key go to the member that took it (the coordinator
|
|
1537
|
+
# that acknowledged a write reads it back even at ONE). 0 (default) = off.
|
|
1538
|
+
# - +logger+ — a Logger (or anything with +warn+) told of ejections and
|
|
1539
|
+
# re-admissions.
|
|
1372
1540
|
#
|
|
1373
|
-
# pool = Skaidb::Pool.new(seeds: ["h1:7000", "h2:7000"], database: "app", maxsize: 8
|
|
1541
|
+
# pool = Skaidb::Pool.new(seeds: ["h1:7000", "h2:7000"], database: "app", maxsize: 8,
|
|
1542
|
+
# load_balancing: :least_latency, discover: true)
|
|
1374
1543
|
# pool.with { |conn| conn.exec("SELECT 1") }
|
|
1375
1544
|
# pool.close
|
|
1376
1545
|
class Pool
|
|
1377
|
-
|
|
1546
|
+
POLICIES = %i[random round_robin least_latency].freeze
|
|
1547
|
+
IDLE_ORDERS = %i[lifo fifo].freeze
|
|
1548
|
+
# Latency EWMA: each round trip moves a member's average 5% toward it,
|
|
1549
|
+
# and no sample counts for more than 10x the larger of the current
|
|
1550
|
+
# average and the ejection floor — one slow query does not eject a
|
|
1551
|
+
# healthy member, a member that stays slow is ejected within a few dozen
|
|
1552
|
+
# statements.
|
|
1553
|
+
EWMA_WEIGHT = 0.05
|
|
1554
|
+
EWMA_CLAMP = 10.0
|
|
1555
|
+
# Round trips a member (and at least one other) needs before its average
|
|
1556
|
+
# can eject it.
|
|
1557
|
+
EWMA_MIN_SAMPLES = 10
|
|
1558
|
+
# least_latency: a member whose last round trip is older than this scores
|
|
1559
|
+
# 0, so a lightly loaded pool keeps re-measuring every member instead of
|
|
1560
|
+
# settling forever on whichever answered fastest once.
|
|
1561
|
+
STALE_SAMPLE = 2.0
|
|
1562
|
+
# Discovery refresh while a listed member is not ready: a member is often
|
|
1563
|
+
# +unreachable+ or +resyncing+ only for seconds (a peer's circuit breaker
|
|
1564
|
+
# right after it restarts), and waiting a whole refresh interval to let
|
|
1565
|
+
# it back in leaves the load on the others.
|
|
1566
|
+
REFRESH_NOT_READY = 5.0
|
|
1567
|
+
# How often the background thread checks for members due a re-probe.
|
|
1568
|
+
MAINTAIN_TICK = 0.25
|
|
1569
|
+
# Read-your-writes keys kept before the expired ones are swept.
|
|
1570
|
+
AFFINITY_SWEEP = 4096
|
|
1571
|
+
private_constant :EWMA_WEIGHT, :EWMA_CLAMP, :EWMA_MIN_SAMPLES, :STALE_SAMPLE,
|
|
1572
|
+
:REFRESH_NOT_READY, :MAINTAIN_TICK, :AFFINITY_SWEEP
|
|
1573
|
+
|
|
1574
|
+
# One member as the pool sees it.
|
|
1575
|
+
class Member
|
|
1576
|
+
attr_reader :endpoint, :host, :port, :seed
|
|
1577
|
+
attr_accessor :canonical, :listed, :ewma, :samples, :last_sample, :errors,
|
|
1578
|
+
:ejected_until, :ejections, :readmitted_at, :in_use, :probing
|
|
1579
|
+
|
|
1580
|
+
def initialize(host, port, seed)
|
|
1581
|
+
@host = host
|
|
1582
|
+
@port = port
|
|
1583
|
+
@endpoint = "#{host}:#{port}" # what the pool dials, and its key
|
|
1584
|
+
@seed = seed # configured: discovery never drops it
|
|
1585
|
+
@canonical = nil # its own SHOW ENDPOINTS name
|
|
1586
|
+
@listed = :ready # :ready, :resyncing, :unreachable, :unlisted
|
|
1587
|
+
@ewma = nil # seconds
|
|
1588
|
+
@samples = 0
|
|
1589
|
+
@last_sample = 0.0
|
|
1590
|
+
@errors = 0 # consecutive transport failures
|
|
1591
|
+
@ejected_until = nil # nil = admitted
|
|
1592
|
+
@ejections = 0
|
|
1593
|
+
@readmitted_at = 0.0
|
|
1594
|
+
@in_use = 0
|
|
1595
|
+
@probing = false
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
private_constant :Member
|
|
1599
|
+
|
|
1600
|
+
def initialize(maxsize: 10, load_balancing: :random, idle_order: :lifo, discover: false,
|
|
1601
|
+
refresh_interval: 60.0, read_your_writes: 0.0, eject_errors: 3,
|
|
1602
|
+
eject_latency_factor: 3.0, eject_latency_floor: 0.05, eject_backoff: 5.0,
|
|
1603
|
+
eject_backoff_max: 60.0, logger: nil, **connect_kwargs)
|
|
1378
1604
|
raise ArgumentError, "maxsize must be >= 1" if maxsize < 1
|
|
1605
|
+
raise ArgumentError, "eject_errors must be >= 1" if eject_errors < 1
|
|
1379
1606
|
|
|
1607
|
+
@policy = choice(load_balancing, POLICIES, "load_balancing")
|
|
1608
|
+
@fifo = choice(idle_order, IDLE_ORDERS, "idle_order") == :fifo
|
|
1380
1609
|
@maxsize = maxsize
|
|
1381
|
-
@
|
|
1610
|
+
@discover = discover ? true : false
|
|
1611
|
+
@refresh_interval = refresh_interval.to_f
|
|
1612
|
+
@ryw = read_your_writes.to_f
|
|
1613
|
+
@eject_errors = eject_errors
|
|
1614
|
+
@eject_factor = eject_latency_factor.to_f
|
|
1615
|
+
@eject_floor = eject_latency_floor.to_f
|
|
1616
|
+
@backoff = eject_backoff.to_f
|
|
1617
|
+
@backoff_max = eject_backoff_max.to_f
|
|
1618
|
+
@logger = logger
|
|
1619
|
+
@conn_opts = Skaidb.connection_options(**connect_kwargs)
|
|
1620
|
+
@port = @conn_opts[:port]
|
|
1621
|
+
seeds = @conn_opts[:seeds]
|
|
1622
|
+
@members = {}
|
|
1623
|
+
(seeds && !seeds.empty? ? seeds : ["#{@conn_opts[:host]}:#{@port}"]).each do |s|
|
|
1624
|
+
host, port = Skaidb.parse_endpoint(s, @port)
|
|
1625
|
+
@members["#{host}:#{port}"] ||= Member.new(host, port, true)
|
|
1626
|
+
end
|
|
1382
1627
|
@idle = []
|
|
1383
1628
|
@mutex = Mutex.new
|
|
1629
|
+
@wake = ConditionVariable.new
|
|
1630
|
+
@rr = -1
|
|
1631
|
+
@affinity = {}
|
|
1632
|
+
@discovery_off = false
|
|
1633
|
+
@next_refresh = 0.0
|
|
1634
|
+
@maintainer = nil
|
|
1384
1635
|
@closed = false
|
|
1385
1636
|
end
|
|
1386
1637
|
|
|
1387
|
-
#
|
|
1388
|
-
|
|
1638
|
+
# -- checkout / checkin --
|
|
1639
|
+
|
|
1640
|
+
# Check out a usable connection: an idle one to the member the policy
|
|
1641
|
+
# picks, or a new one dialed to it (falling back to the other members).
|
|
1642
|
+
# +affinity+ scopes read-your-writes (nil = pool-wide).
|
|
1643
|
+
def checkout(affinity: nil)
|
|
1389
1644
|
loop do
|
|
1390
|
-
conn =
|
|
1645
|
+
conn = order = nil
|
|
1646
|
+
stale = []
|
|
1647
|
+
@mutex.synchronize do
|
|
1391
1648
|
raise Error, "pool is closed" if @closed
|
|
1392
1649
|
|
|
1393
|
-
|
|
1650
|
+
target = pick(affinity)
|
|
1651
|
+
conn, stale = take_idle(target)
|
|
1652
|
+
order = order_for(target) if conn.nil?
|
|
1394
1653
|
end
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1654
|
+
stale.each { |c| close_quietly(c) }
|
|
1655
|
+
if conn.nil?
|
|
1656
|
+
conn = Connection.new(**@conn_opts, endpoints: order, observer: self)
|
|
1657
|
+
conn.pool_member = conn.endpoint
|
|
1658
|
+
discover_through(conn)
|
|
1659
|
+
unless conn.usable?
|
|
1660
|
+
close_quietly(conn)
|
|
1661
|
+
next
|
|
1662
|
+
end
|
|
1663
|
+
elsif !conn.usable?
|
|
1664
|
+
# A connection the server closed while it sat idle still looks
|
|
1665
|
+
# fine locally, so check before handing it out.
|
|
1666
|
+
close_quietly(conn)
|
|
1667
|
+
next
|
|
1404
1668
|
end
|
|
1669
|
+
@mutex.synchronize do
|
|
1670
|
+
m = @members[conn.pool_member]
|
|
1671
|
+
m.in_use += 1 if m
|
|
1672
|
+
conn.pool_affinity = affinity
|
|
1673
|
+
conn.pool_out = true
|
|
1674
|
+
end
|
|
1675
|
+
return conn
|
|
1405
1676
|
end
|
|
1406
1677
|
end
|
|
1407
1678
|
|
|
1408
|
-
# Return a connection, closing it if broken
|
|
1679
|
+
# Return a connection, closing it if it is broken, its member is ejected
|
|
1680
|
+
# or gone, or the pool is full.
|
|
1409
1681
|
def checkin(conn)
|
|
1410
1682
|
keep = @mutex.synchronize do
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1683
|
+
if conn.pool_out
|
|
1684
|
+
conn.pool_out = false
|
|
1685
|
+
counted = @members[conn.pool_member]
|
|
1686
|
+
counted.in_use -= 1 if counted && counted.in_use.positive?
|
|
1687
|
+
end
|
|
1688
|
+
m = @members[conn.pool_member]
|
|
1689
|
+
if !@closed && conn.usable? && m && usable?(m) && @idle.length < @maxsize
|
|
1690
|
+
@idle.push(conn)
|
|
1691
|
+
true
|
|
1692
|
+
else
|
|
1693
|
+
false
|
|
1694
|
+
end
|
|
1421
1695
|
end
|
|
1696
|
+
close_quietly(conn) unless keep
|
|
1422
1697
|
end
|
|
1423
1698
|
|
|
1424
1699
|
# Run the block with a checked-out connection, returning it afterwards.
|
|
1425
|
-
|
|
1426
|
-
|
|
1700
|
+
# +affinity+ scopes read-your-writes (nil = pool-wide).
|
|
1701
|
+
def with(affinity: nil)
|
|
1702
|
+
conn = checkout(affinity: affinity)
|
|
1427
1703
|
begin
|
|
1428
1704
|
yield conn
|
|
1429
1705
|
ensure
|
|
@@ -1431,20 +1707,400 @@ module Skaidb
|
|
|
1431
1707
|
end
|
|
1432
1708
|
end
|
|
1433
1709
|
|
|
1434
|
-
#
|
|
1710
|
+
# The members as the pool sees them, for monitoring: one Hash each with
|
|
1711
|
+
# +:endpoint+ (what the pool dials), +:canonical+ (the member's own name,
|
|
1712
|
+
# once discovered), +:state+ (+:ready+, +:ejected+, +:resyncing+,
|
|
1713
|
+
# +:unreachable+, +:unlisted+), +:latency_ms+ (the round-trip average,
|
|
1714
|
+
# nil until measured), +:in_use+, +:idle+ and +:seed+.
|
|
1715
|
+
def members
|
|
1716
|
+
@mutex.synchronize do
|
|
1717
|
+
idle = Hash.new(0)
|
|
1718
|
+
@idle.each { |c| idle[c.pool_member] += 1 }
|
|
1719
|
+
@members.values.sort_by { |m| [m.host, m.port] }.map do |m|
|
|
1720
|
+
{ endpoint: m.endpoint, canonical: m.canonical,
|
|
1721
|
+
state: m.ejected_until ? :ejected : m.listed,
|
|
1722
|
+
latency_ms: m.ewma && m.ewma * 1000.0, in_use: m.in_use,
|
|
1723
|
+
idle: idle[m.endpoint], seed: m.seed }
|
|
1724
|
+
end
|
|
1725
|
+
end
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1728
|
+
# Close the pool and every idle connection, and stop the background
|
|
1729
|
+
# thread. Checked-out connections are closed when they are returned.
|
|
1435
1730
|
def close
|
|
1436
1731
|
drained = @mutex.synchronize do
|
|
1437
1732
|
@closed = true
|
|
1733
|
+
@wake.broadcast
|
|
1438
1734
|
d = @idle
|
|
1439
1735
|
@idle = []
|
|
1440
1736
|
d
|
|
1441
1737
|
end
|
|
1442
|
-
drained.each
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1738
|
+
drained.each { |c| close_quietly(c) }
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1741
|
+
# -- what connections report (internal: called by Connection, any thread) --
|
|
1742
|
+
|
|
1743
|
+
# Internal: the order a pooled connection re-dials in — the usable
|
|
1744
|
+
# members first, +exclude+ (the member it lost) last.
|
|
1745
|
+
def dial_order(exclude)
|
|
1746
|
+
@mutex.synchronize { order_for(nil, exclude) }
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
# Internal: a round trip on +conn+ took +elapsed+ seconds.
|
|
1750
|
+
def note_round_trip(conn, elapsed)
|
|
1751
|
+
@mutex.synchronize do
|
|
1752
|
+
ep = conn.endpoint
|
|
1753
|
+
if ep != conn.pool_member
|
|
1754
|
+
# The connection re-dialed to another member mid-checkout.
|
|
1755
|
+
if conn.pool_out
|
|
1756
|
+
old = @members[conn.pool_member]
|
|
1757
|
+
old.in_use -= 1 if old && old.in_use.positive?
|
|
1758
|
+
moved = @members[ep]
|
|
1759
|
+
moved.in_use += 1 if moved
|
|
1760
|
+
end
|
|
1761
|
+
conn.pool_member = ep
|
|
1762
|
+
end
|
|
1763
|
+
m = @members[ep]
|
|
1764
|
+
next if m.nil?
|
|
1765
|
+
|
|
1766
|
+
m.errors = 0
|
|
1767
|
+
if m.ewma.nil?
|
|
1768
|
+
m.ewma = elapsed
|
|
1769
|
+
else
|
|
1770
|
+
cap = EWMA_CLAMP * [m.ewma, @eject_floor].max
|
|
1771
|
+
m.ewma += EWMA_WEIGHT * ([elapsed, cap].min - m.ewma)
|
|
1772
|
+
end
|
|
1773
|
+
m.samples += 1
|
|
1774
|
+
m.last_sample = now
|
|
1775
|
+
next unless m.samples >= EWMA_MIN_SAMPLES && @eject_factor.positive? && m.ejected_until.nil?
|
|
1776
|
+
|
|
1777
|
+
others = @members.each_value.select do |o|
|
|
1778
|
+
!o.equal?(m) && usable?(o) && o.ewma && o.samples >= EWMA_MIN_SAMPLES
|
|
1779
|
+
end.map(&:ewma).sort
|
|
1780
|
+
next if others.empty?
|
|
1781
|
+
|
|
1782
|
+
median = others[(others.length - 1) / 2]
|
|
1783
|
+
if m.ewma > @eject_factor * median && m.ewma - median > @eject_floor
|
|
1784
|
+
eject(m, format("latency %.1f ms vs median %.1f ms", m.ewma * 1000, median * 1000))
|
|
1785
|
+
end
|
|
1786
|
+
end
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
# Internal: a dial or a request to +endpoint+ failed in transport.
|
|
1790
|
+
def note_failure(endpoint)
|
|
1791
|
+
@mutex.synchronize do
|
|
1792
|
+
m = @members[endpoint]
|
|
1793
|
+
next if m.nil?
|
|
1794
|
+
|
|
1795
|
+
m.errors += 1
|
|
1796
|
+
eject(m, "#{m.errors} consecutive connection failures") if m.errors >= @eject_errors && m.ejected_until.nil?
|
|
1797
|
+
end
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
# Internal: +sql+ succeeded on +conn+; a write pins the connection's
|
|
1801
|
+
# affinity key to its member for +read_your_writes+ seconds.
|
|
1802
|
+
def note_statement(conn, sql)
|
|
1803
|
+
return if @ryw <= 0 || !conn.pool_out || !Skaidb.write_statement?(sql)
|
|
1804
|
+
|
|
1805
|
+
t = now
|
|
1806
|
+
@mutex.synchronize do
|
|
1807
|
+
@affinity.select! { |_, (_, untl)| untl > t } if @affinity.length >= AFFINITY_SWEEP
|
|
1808
|
+
@affinity[conn.pool_affinity] = [conn.endpoint, t + @ryw]
|
|
1809
|
+
end
|
|
1810
|
+
end
|
|
1811
|
+
|
|
1812
|
+
# Internal: +conn+ was closed while checked out instead of returned.
|
|
1813
|
+
def note_closed(conn)
|
|
1814
|
+
@mutex.synchronize do
|
|
1815
|
+
next unless conn.pool_out
|
|
1816
|
+
|
|
1817
|
+
conn.pool_out = false
|
|
1818
|
+
m = @members[conn.pool_member]
|
|
1819
|
+
m.in_use -= 1 if m && m.in_use.positive?
|
|
1820
|
+
end
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
private
|
|
1824
|
+
|
|
1825
|
+
def choice(value, allowed, name)
|
|
1826
|
+
v = value.to_s.downcase.to_sym if value.is_a?(String) || value.is_a?(Symbol)
|
|
1827
|
+
return v if allowed.include?(v)
|
|
1828
|
+
|
|
1829
|
+
names = allowed.map(&:inspect)
|
|
1830
|
+
raise ArgumentError, "unknown #{name} #{value.inspect} (use #{names[0..-2].join(', ')} or #{names[-1]})"
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
def now
|
|
1834
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1835
|
+
end
|
|
1836
|
+
|
|
1837
|
+
def close_quietly(conn)
|
|
1838
|
+
conn.close
|
|
1839
|
+
rescue StandardError
|
|
1840
|
+
nil
|
|
1841
|
+
end
|
|
1842
|
+
|
|
1843
|
+
def log(message)
|
|
1844
|
+
@logger&.warn("skaidb pool: #{message}")
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
# -- choosing a member (callers hold @mutex) --
|
|
1848
|
+
|
|
1849
|
+
def usable?(m)
|
|
1850
|
+
m.ejected_until.nil? && m.listed == :ready
|
|
1851
|
+
end
|
|
1852
|
+
|
|
1853
|
+
# The member this checkout should go to, or nil for "any" (the random
|
|
1854
|
+
# policy, or no usable member at all).
|
|
1855
|
+
def pick(affinity)
|
|
1856
|
+
if @ryw.positive? && (hit = @affinity[affinity])
|
|
1857
|
+
ep, untl = hit
|
|
1858
|
+
m = @members[ep]
|
|
1859
|
+
if now >= untl || m.nil?
|
|
1860
|
+
@affinity.delete(affinity)
|
|
1861
|
+
elsif usable?(m)
|
|
1862
|
+
return m
|
|
1863
|
+
end
|
|
1864
|
+
end
|
|
1865
|
+
usable = @members.values.select { |m| usable?(m) }
|
|
1866
|
+
return nil if usable.empty? || @policy == :random
|
|
1867
|
+
|
|
1868
|
+
if @policy == :round_robin
|
|
1869
|
+
usable.sort_by! { |m| [m.host, m.port] }
|
|
1870
|
+
@rr = (@rr + 1) % usable.length
|
|
1871
|
+
return usable[@rr]
|
|
1872
|
+
end
|
|
1873
|
+
return usable[0] if usable.length == 1
|
|
1874
|
+
|
|
1875
|
+
a, b = usable.sample(2)
|
|
1876
|
+
score(a) <= score(b) ? a : b
|
|
1877
|
+
end
|
|
1878
|
+
|
|
1879
|
+
# A member never measured, or not lately, scores 0: it is tried and
|
|
1880
|
+
# measured.
|
|
1881
|
+
def score(m)
|
|
1882
|
+
return 0.0 if m.ewma.nil? || now - m.last_sample > STALE_SAMPLE
|
|
1883
|
+
|
|
1884
|
+
m.ewma * (m.in_use + 1)
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1887
|
+
# Remove and return an idle connection (to +target+, or to any usable
|
|
1888
|
+
# member), plus the idle connections found to belong to members that are
|
|
1889
|
+
# no longer usable (to close outside the lock).
|
|
1890
|
+
def take_idle(target)
|
|
1891
|
+
stale = []
|
|
1892
|
+
found = nil
|
|
1893
|
+
indexes = @fifo ? (0...@idle.length) : (@idle.length - 1).downto(0)
|
|
1894
|
+
indexes.each do |i|
|
|
1895
|
+
m = @members[@idle[i].pool_member]
|
|
1896
|
+
if m.nil? || !usable?(m)
|
|
1897
|
+
stale << i
|
|
1898
|
+
elsif target.nil? || m.equal?(target)
|
|
1899
|
+
found = i
|
|
1900
|
+
break
|
|
1901
|
+
end
|
|
1902
|
+
end
|
|
1903
|
+
picked = found && @idle[found]
|
|
1904
|
+
stale_conns = stale.map { |i| @idle[i] }
|
|
1905
|
+
(found ? stale + [found] : stale).sort.reverse_each { |i| @idle.delete_at(i) }
|
|
1906
|
+
[picked, stale_conns]
|
|
1907
|
+
end
|
|
1908
|
+
|
|
1909
|
+
# [host, port] pairs to dial, best first: +first+, the other usable
|
|
1910
|
+
# members in random order, then the rest (resyncing/unreachable before
|
|
1911
|
+
# ejected, soonest re-admission first) — a dial never has nowhere to go
|
|
1912
|
+
# while any member is configured. +exclude+ goes last.
|
|
1913
|
+
def order_for(first, exclude = nil)
|
|
1914
|
+
all = @members.values
|
|
1915
|
+
usable = all.select { |m| !m.equal?(first) && usable?(m) }.shuffle
|
|
1916
|
+
rest = all.select { |m| !m.equal?(first) && !usable?(m) }
|
|
1917
|
+
rest = rest.sort_by { |m| m.ejected_until ? [1, m.ejected_until] : [0, 0.0] }
|
|
1918
|
+
order = (first ? [first] : []) + usable + rest
|
|
1919
|
+
lost = exclude && order.length > 1 && order.find { |m| m.endpoint == exclude }
|
|
1920
|
+
order = order.reject { |m| m.equal?(lost) } + [lost] if lost
|
|
1921
|
+
order.map { |m| [m.host, m.port] }
|
|
1922
|
+
end
|
|
1923
|
+
|
|
1924
|
+
# -- ejection and re-admission --
|
|
1925
|
+
|
|
1926
|
+
# Take +m+ out of rotation (caller holds @mutex) — unless it is the last
|
|
1927
|
+
# usable member: a slow or flaky member beats none.
|
|
1928
|
+
def eject(m, why)
|
|
1929
|
+
return unless @members.each_value.any? { |o| !o.equal?(m) && usable?(o) }
|
|
1930
|
+
|
|
1931
|
+
t = now
|
|
1932
|
+
m.ejections = 0 if t - m.readmitted_at > @backoff_max # healthy for a while: start over
|
|
1933
|
+
m.ejections += 1
|
|
1934
|
+
m.ejected_until = t + backoff_for(m)
|
|
1935
|
+
log("ejecting #{m.endpoint} (#{why})")
|
|
1936
|
+
ensure_maintainer
|
|
1937
|
+
end
|
|
1938
|
+
|
|
1939
|
+
def backoff_for(m)
|
|
1940
|
+
[@backoff * (2**[m.ejections - 1, 0].max), @backoff_max].min
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
# Start the background thread (caller holds @mutex): it re-probes ejected
|
|
1944
|
+
# members and refreshes discovery, and ends when the pool closes. Ruby
|
|
1945
|
+
# never waits for it at exit.
|
|
1946
|
+
def ensure_maintainer
|
|
1947
|
+
return if @maintainer || @closed
|
|
1948
|
+
|
|
1949
|
+
@maintainer = Thread.new { maintain }
|
|
1950
|
+
@maintainer.name = "skaidb-pool"
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
def maintain
|
|
1954
|
+
loop do
|
|
1955
|
+
due = []
|
|
1956
|
+
refresh = false
|
|
1957
|
+
@mutex.synchronize do
|
|
1958
|
+
@wake.wait(@mutex, MAINTAIN_TICK) unless @closed
|
|
1959
|
+
return if @closed
|
|
1960
|
+
|
|
1961
|
+
t = now
|
|
1962
|
+
due = @members.each_value.select { |m| m.ejected_until && t >= m.ejected_until && !m.probing }
|
|
1963
|
+
due.each { |m| m.probing = true }
|
|
1964
|
+
refresh = @discover && !@discovery_off && t >= @next_refresh
|
|
1447
1965
|
end
|
|
1966
|
+
due.each { |m| probe(m) }
|
|
1967
|
+
refresh_members if refresh
|
|
1968
|
+
end
|
|
1969
|
+
end
|
|
1970
|
+
|
|
1971
|
+
# Dial +m+ alone and run one statement: success re-admits it (the probe
|
|
1972
|
+
# connection joins the idle set), failure ejects it again for twice as
|
|
1973
|
+
# long.
|
|
1974
|
+
def probe(m)
|
|
1975
|
+
conn = nil
|
|
1976
|
+
ok = begin
|
|
1977
|
+
conn = Connection.new(**@conn_opts, endpoints: [[m.host, m.port]])
|
|
1978
|
+
conn.exec("SHOW DATABASES")
|
|
1979
|
+
true
|
|
1980
|
+
rescue StandardError
|
|
1981
|
+
false
|
|
1982
|
+
end
|
|
1983
|
+
keep = false
|
|
1984
|
+
@mutex.synchronize do
|
|
1985
|
+
m.probing = false
|
|
1986
|
+
if ok && !@closed
|
|
1987
|
+
m.ejected_until = nil
|
|
1988
|
+
m.errors = 0
|
|
1989
|
+
m.ewma = nil
|
|
1990
|
+
m.samples = 0
|
|
1991
|
+
m.readmitted_at = now
|
|
1992
|
+
log("re-admitted #{m.endpoint}")
|
|
1993
|
+
if @idle.length < @maxsize
|
|
1994
|
+
conn.observer = self
|
|
1995
|
+
conn.pool_member = m.endpoint
|
|
1996
|
+
@idle.push(conn)
|
|
1997
|
+
keep = true
|
|
1998
|
+
end
|
|
1999
|
+
else
|
|
2000
|
+
m.ejections += 1
|
|
2001
|
+
m.ejected_until = now + backoff_for(m)
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
close_quietly(conn) if conn && !keep
|
|
2005
|
+
end
|
|
2006
|
+
|
|
2007
|
+
# -- discovery --
|
|
2008
|
+
|
|
2009
|
+
# Run discovery on a freshly dialed connection when the member it reached
|
|
2010
|
+
# has not named itself yet (once per member, so seeds given as host names
|
|
2011
|
+
# are matched to the endpoints the server reports).
|
|
2012
|
+
def discover_through(conn)
|
|
2013
|
+
return if !@discover || @discovery_off
|
|
2014
|
+
|
|
2015
|
+
ask = @mutex.synchronize do
|
|
2016
|
+
m = @members[conn.pool_member]
|
|
2017
|
+
next false if m.nil? || m.canonical
|
|
2018
|
+
|
|
2019
|
+
ensure_maintainer
|
|
2020
|
+
true
|
|
2021
|
+
end
|
|
2022
|
+
list_through(conn) if ask
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
def refresh_members
|
|
2026
|
+
conn = begin
|
|
2027
|
+
checkout
|
|
2028
|
+
rescue StandardError
|
|
2029
|
+
@mutex.synchronize { @next_refresh = now + @refresh_interval }
|
|
2030
|
+
return
|
|
2031
|
+
end
|
|
2032
|
+
begin
|
|
2033
|
+
list_through(conn)
|
|
2034
|
+
ensure
|
|
2035
|
+
checkin(conn)
|
|
2036
|
+
end
|
|
2037
|
+
end
|
|
2038
|
+
|
|
2039
|
+
def list_through(conn)
|
|
2040
|
+
rows = begin
|
|
2041
|
+
conn.exec("SHOW ENDPOINTS").rows
|
|
2042
|
+
rescue QueryError
|
|
2043
|
+
# A server that predates the statement: keep the seeds.
|
|
2044
|
+
@mutex.synchronize { @discovery_off = true }
|
|
2045
|
+
return
|
|
2046
|
+
rescue Error
|
|
2047
|
+
return
|
|
2048
|
+
end
|
|
2049
|
+
if rows.empty?
|
|
2050
|
+
# A standalone server: nothing to discover, ever.
|
|
2051
|
+
@mutex.synchronize { @discovery_off = true }
|
|
2052
|
+
return
|
|
2053
|
+
end
|
|
2054
|
+
@mutex.synchronize do
|
|
2055
|
+
apply_listing(conn.pool_member, rows)
|
|
2056
|
+
interval = @refresh_interval
|
|
2057
|
+
if @members.each_value.any? { |m| m.listed == :resyncing || m.listed == :unreachable }
|
|
2058
|
+
interval = [interval, REFRESH_NOT_READY].min
|
|
2059
|
+
end
|
|
2060
|
+
@next_refresh = now + interval
|
|
2061
|
+
end
|
|
2062
|
+
end
|
|
2063
|
+
|
|
2064
|
+
# Fold a non-empty SHOW ENDPOINTS answer (caller holds @mutex).
|
|
2065
|
+
def apply_listing(dialed, rows)
|
|
2066
|
+
listed = {}
|
|
2067
|
+
rows.each do |endpoint, state, local|
|
|
2068
|
+
listed[endpoint] = state
|
|
2069
|
+
next unless local
|
|
2070
|
+
|
|
2071
|
+
me = @members[dialed]
|
|
2072
|
+
me.canonical = endpoint if me
|
|
2073
|
+
end
|
|
2074
|
+
# A member reported under the name of one already known (a seed given
|
|
2075
|
+
# as a host name) is that member, not a new one.
|
|
2076
|
+
by_name = {}
|
|
2077
|
+
@members.each_value do |m|
|
|
2078
|
+
by_name[m.canonical] = m if m.canonical && (!by_name.key?(m.canonical) || m.seed)
|
|
2079
|
+
end
|
|
2080
|
+
@members.delete_if { |_, m| m.canonical && !by_name[m.canonical].equal?(m) } # a duplicate of a seed
|
|
2081
|
+
seen = {}.compare_by_identity
|
|
2082
|
+
listed.each do |endpoint, state|
|
|
2083
|
+
m = by_name[endpoint]
|
|
2084
|
+
if m.nil?
|
|
2085
|
+
host, port = Skaidb.parse_endpoint(endpoint, @port)
|
|
2086
|
+
m = (@members["#{host}:#{port}"] ||= Member.new(host, port, false))
|
|
2087
|
+
m.canonical = endpoint
|
|
2088
|
+
end
|
|
2089
|
+
state = %w[ready resyncing unreachable].include?(state) ? state.to_sym : :ready
|
|
2090
|
+
if state == :ready && m.listed != :ready
|
|
2091
|
+
# Back in rotation: measured afresh, like a re-admission.
|
|
2092
|
+
m.ewma = nil
|
|
2093
|
+
m.samples = 0
|
|
2094
|
+
m.errors = 0
|
|
2095
|
+
end
|
|
2096
|
+
m.listed = state
|
|
2097
|
+
seen[m] = true
|
|
2098
|
+
end
|
|
2099
|
+
@members.delete_if do |_, m|
|
|
2100
|
+
next false if seen[m] || (m.seed && m.canonical.nil?) # listed, or a seed not identified yet
|
|
2101
|
+
|
|
2102
|
+
m.listed = :unlisted if m.seed # configured, but no longer a member
|
|
2103
|
+
!m.seed
|
|
1448
2104
|
end
|
|
1449
2105
|
end
|
|
1450
2106
|
end
|