skaidb 1.0.3 → 1.2.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 +60 -0
- data/README.md +82 -15
- data/docs/api.md +47 -11
- data/docs/pooling.md +125 -9
- data/docs/streaming.md +5 -2
- data/docs/tls.md +31 -3
- data/lib/skaidb.rb +825 -97
- metadata +2 -2
data/lib/skaidb.rb
CHANGED
|
@@ -34,7 +34,7 @@ require "bigdecimal"
|
|
|
34
34
|
module Skaidb
|
|
35
35
|
# The package version — the single source of truth. The gemspec reads it,
|
|
36
36
|
# and it is what the driver reports to the server in the Hello frame.
|
|
37
|
-
VERSION = "1.0
|
|
37
|
+
VERSION = "1.2.0"
|
|
38
38
|
|
|
39
39
|
# Base class for every error raised by this driver.
|
|
40
40
|
class Error < StandardError; end
|
|
@@ -73,6 +73,29 @@ module Skaidb
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Login mechanisms (the optional last byte of AuthStart, PROTOCOL.md §2.4).
|
|
77
|
+
module AuthMechanism
|
|
78
|
+
# SCRAM-SHA-256 with +user+ / +password+ (the default).
|
|
79
|
+
SCRAM = :scram
|
|
80
|
+
# EXTERNAL: the TLS client certificate is the credential.
|
|
81
|
+
CERTIFICATE = :certificate
|
|
82
|
+
|
|
83
|
+
WIRE = { SCRAM => 0, CERTIFICATE => 2 }.freeze
|
|
84
|
+
ALIASES = {
|
|
85
|
+
"scram" => SCRAM, "password" => SCRAM,
|
|
86
|
+
"certificate" => CERTIFICATE, "external" => CERTIFICATE, "x509" => CERTIFICATE
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# Resolve +:scram+ / +:certificate+ (or a String, or an alias:
|
|
90
|
+
# "password", "external", "x509") into +:scram+ or +:certificate+.
|
|
91
|
+
def self.resolve(value)
|
|
92
|
+
m = ALIASES[value.to_s.downcase] if value.is_a?(String) || value.is_a?(Symbol)
|
|
93
|
+
return m if m
|
|
94
|
+
|
|
95
|
+
raise Error, "unknown auth_mechanism #{value.inspect} (use :scram or :certificate)"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
76
99
|
# Value type tags (§4 of PROTOCOL.md).
|
|
77
100
|
module Tags
|
|
78
101
|
NULL = 0
|
|
@@ -490,12 +513,25 @@ module Skaidb
|
|
|
490
513
|
|
|
491
514
|
# ---- SCRAM-SHA-256 (§2) --------------------------------------------------
|
|
492
515
|
|
|
516
|
+
# The SaltedPassword: PBKDF2-HMAC-SHA-256 of the UTF-8 password, 32 bytes.
|
|
517
|
+
def self.salted_password(password, salt, iterations)
|
|
518
|
+
OpenSSL::KDF.pbkdf2_hmac(
|
|
519
|
+
password.to_s.dup.force_encoding("UTF-8"), salt: salt, iterations: iterations, length: 32, hash: "sha256"
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The AuthMessage both sides sign: username, client nonce, server nonce,
|
|
524
|
+
# lowercase-hex salt and decimal iteration count, joined with NUL bytes.
|
|
525
|
+
def self.auth_message(username, client_nonce, server_nonce, salt, iterations)
|
|
526
|
+
[username, client_nonce, server_nonce, salt.unpack1("H*"), iterations.to_s]
|
|
527
|
+
.map { |part| part.to_s.dup.force_encoding("UTF-8") }
|
|
528
|
+
.join("\0")
|
|
529
|
+
end
|
|
530
|
+
|
|
493
531
|
# Compute the client proof and the expected server signature.
|
|
494
532
|
def self.scram(password, salt, iterations, auth_message)
|
|
495
533
|
digest = OpenSSL::Digest::SHA256
|
|
496
|
-
salted =
|
|
497
|
-
password, salt: salt, iterations: iterations, length: 32, hash: "sha256"
|
|
498
|
-
)
|
|
534
|
+
salted = salted_password(password, salt, iterations)
|
|
499
535
|
client_key = OpenSSL::HMAC.digest(digest.new, salted, "Client Key")
|
|
500
536
|
stored_key = OpenSSL::Digest::SHA256.digest(client_key)
|
|
501
537
|
client_sig = OpenSSL::HMAC.digest(digest.new, stored_key, auth_message)
|
|
@@ -525,6 +561,23 @@ module Skaidb
|
|
|
525
561
|
[bytes].pack("V") + b.b
|
|
526
562
|
end
|
|
527
563
|
|
|
564
|
+
# AuthStart for the certificate login (mechanism EXTERNAL): username (may
|
|
565
|
+
# be empty), an empty client nonce, mechanism byte 2.
|
|
566
|
+
def self.certificate_auth_start(user)
|
|
567
|
+
[10].pack("C") + enc_str(user) + enc_str("") + [AuthMechanism::WIRE[AuthMechanism::CERTIFICATE]].pack("C")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Check the AuthOutcome that answers a certificate AuthStart: returns on
|
|
571
|
+
# Ok (the signature is deliberately not checked), raises ConnectionError
|
|
572
|
+
# with the server's reason on Denied.
|
|
573
|
+
def self.parse_certificate_outcome(payload)
|
|
574
|
+
r = Reader.new(payload)
|
|
575
|
+
raise ConnectionError, "bad handshake outcome" unless r.u8 == 13
|
|
576
|
+
raise ConnectionError, "authentication denied: #{r.text}" unless r.u8 == 1
|
|
577
|
+
|
|
578
|
+
nil
|
|
579
|
+
end
|
|
580
|
+
|
|
528
581
|
# ---- Result --------------------------------------------------------------
|
|
529
582
|
|
|
530
583
|
# A query result, shaped like +PG::Result+. Enumerable over row Hashes
|
|
@@ -543,8 +596,15 @@ module Skaidb
|
|
|
543
596
|
# whose body EMITted), in order; this Result is the LAST of them.
|
|
544
597
|
# Empty for an ordinary single-set reply.
|
|
545
598
|
attr_reader :result_sets
|
|
546
|
-
|
|
547
|
-
|
|
599
|
+
# @return [Symbol] what the server answered: +:rows+ (a result set, or
|
|
600
|
+
# several — see +result_sets+), +:mutation+ (an affected-row count in
|
|
601
|
+
# +cmd_tuples+), +:ddl+ (a bare acknowledgement: no rows, no count), or
|
|
602
|
+
# +:stream+ (returned by Connection#stream once every row was yielded:
|
|
603
|
+
# +fields+ holds the column names, +rows+ is empty).
|
|
604
|
+
attr_reader :kind
|
|
605
|
+
|
|
606
|
+
def initialize(fields:, rows:, cmd_tuples: 0, result_sets: [], kind: :rows)
|
|
607
|
+
@kind = kind
|
|
548
608
|
@result_sets = result_sets
|
|
549
609
|
@fields = fields
|
|
550
610
|
@rows = rows
|
|
@@ -617,11 +677,37 @@ module Skaidb
|
|
|
617
677
|
class Connection
|
|
618
678
|
# @return [Boolean] whether the connection has been closed
|
|
619
679
|
attr_reader :closed
|
|
620
|
-
|
|
680
|
+
# @return [String, nil] the member this connection reached, as the
|
|
681
|
+
# "host:port" it dialed (nil until it connects)
|
|
682
|
+
attr_reader :endpoint
|
|
683
|
+
# Internal, for Pool: the pool told of this connection's round trips,
|
|
684
|
+
# transport failures and writes, and asked for the dial order when it
|
|
685
|
+
# re-dials (nil outside a pool); the member the pool counts it against;
|
|
686
|
+
# the affinity key it was checked out under; whether it is checked out.
|
|
687
|
+
attr_accessor :observer, :pool_member, :pool_affinity, :pool_out
|
|
688
|
+
|
|
689
|
+
# +endpoints+ and +observer+ are internal, for Pool: an explicit dial
|
|
690
|
+
# order of [host, port] pairs (no shuffle) and the pool that owns the
|
|
691
|
+
# connection.
|
|
621
692
|
def initialize(host:, port:, user:, password:, consistency:, timeout:,
|
|
622
693
|
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
623
|
-
tls_server_name: "skaidb", seeds: nil
|
|
694
|
+
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
695
|
+
tls_client_key: nil, auth_mechanism: :scram, endpoints: nil,
|
|
696
|
+
observer: nil)
|
|
697
|
+
@observer = observer
|
|
698
|
+
@endpoint = nil
|
|
699
|
+
@pool_member = nil
|
|
700
|
+
@pool_affinity = nil
|
|
701
|
+
@pool_out = false
|
|
624
702
|
@consistency = Consistency.resolve(consistency)
|
|
703
|
+
@auth_mechanism = AuthMechanism.resolve(auth_mechanism)
|
|
704
|
+
if tls_client_cert.to_s.empty? != tls_client_key.to_s.empty?
|
|
705
|
+
raise Error, "tls_client_cert and tls_client_key go together"
|
|
706
|
+
end
|
|
707
|
+
if @auth_mechanism == AuthMechanism::CERTIFICATE && (!tls || tls_client_cert.to_s.empty?)
|
|
708
|
+
raise Error, "certificate authentication needs TLS with a client certificate " \
|
|
709
|
+
"(tls_client_cert: and tls_client_key:)"
|
|
710
|
+
end
|
|
625
711
|
@mutex = Mutex.new
|
|
626
712
|
@closed = false
|
|
627
713
|
# Transport died; the next statement re-dials (see ensure_live!).
|
|
@@ -634,7 +720,8 @@ module Skaidb
|
|
|
634
720
|
@dial_args = { host: host, port: port, user: user, password: password,
|
|
635
721
|
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
636
722
|
tls_insecure: tls_insecure, tls_server_name: tls_server_name,
|
|
637
|
-
seeds: seeds
|
|
723
|
+
seeds: seeds, tls_client_cert: tls_client_cert,
|
|
724
|
+
tls_client_key: tls_client_key, endpoints: endpoints }
|
|
638
725
|
dial!
|
|
639
726
|
end
|
|
640
727
|
|
|
@@ -656,22 +743,39 @@ module Skaidb
|
|
|
656
743
|
# Seeds: try each until one connects AND authenticates — a node that
|
|
657
744
|
# accepts TCP while unhealthy must not swallow the attempt. skaidb is
|
|
658
745
|
# leaderless, so any node serves; the order is shuffled so many
|
|
659
|
-
# clients spread instead of stampeding the first entry.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
746
|
+
# clients spread instead of stampeding the first entry. A pooled
|
|
747
|
+
# connection dials in the pool's order instead: the member the pool
|
|
748
|
+
# picked first, and on a re-dial the usable members with the one it
|
|
749
|
+
# lost last.
|
|
750
|
+
endpoints = if @observer && @endpoint
|
|
751
|
+
@observer.dial_order(@endpoint)
|
|
752
|
+
elsif @dial_args[:endpoints]
|
|
753
|
+
@dial_args[:endpoints]
|
|
754
|
+
else
|
|
755
|
+
(seeds && !seeds.empty? ? seeds : ["#{host}:#{port}"]).map do |sd|
|
|
756
|
+
Skaidb.parse_endpoint(sd, port)
|
|
757
|
+
end.shuffle
|
|
758
|
+
end
|
|
664
759
|
last = nil
|
|
665
760
|
endpoints.each do |(h, prt)|
|
|
666
761
|
begin
|
|
667
762
|
@sock = Socket.tcp(h, prt, connect_timeout: timeout)
|
|
668
763
|
@sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
669
|
-
|
|
670
|
-
|
|
764
|
+
if tls
|
|
765
|
+
@sock = tls_wrap(@sock, tls_ca, tls_insecure, tls_server_name,
|
|
766
|
+
@dial_args[:tls_client_cert], @dial_args[:tls_client_key])
|
|
767
|
+
end
|
|
768
|
+
if @auth_mechanism == AuthMechanism::CERTIFICATE
|
|
769
|
+
handshake_certificate(user.to_s)
|
|
770
|
+
else
|
|
771
|
+
handshake(user.to_s, password.to_s)
|
|
772
|
+
end
|
|
773
|
+
@endpoint = "#{h}:#{prt}"
|
|
671
774
|
last = nil
|
|
672
775
|
break
|
|
673
776
|
rescue StandardError => e
|
|
674
777
|
last = e
|
|
778
|
+
@observer.note_failure("#{h}:#{prt}") if @observer && Skaidb.member_fault?(e)
|
|
675
779
|
begin
|
|
676
780
|
@sock&.close
|
|
677
781
|
rescue StandardError
|
|
@@ -710,8 +814,16 @@ module Skaidb
|
|
|
710
814
|
# unreachable. +tls_server_name+ must match a SAN on the server
|
|
711
815
|
# certificate — skaidb's own certs carry DNS:skaidb, which is usually NOT
|
|
712
816
|
# the address dialled.
|
|
713
|
-
|
|
817
|
+
#
|
|
818
|
+
# +client_cert+ / +client_key+ (PEM files) present a client certificate in
|
|
819
|
+
# the TLS handshake: the credential of the certificate login, and what a
|
|
820
|
+
# server that verifies client certificates asks for.
|
|
821
|
+
def tls_wrap(sock, ca_file, insecure, server_name, client_cert = nil, client_key = nil)
|
|
714
822
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
823
|
+
if client_cert && !client_cert.to_s.empty?
|
|
824
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read(client_cert))
|
|
825
|
+
ctx.key = OpenSSL::PKey.read(File.read(client_key))
|
|
826
|
+
end
|
|
715
827
|
if insecure
|
|
716
828
|
# Encrypts, but authenticates nothing: a man in the middle can present
|
|
717
829
|
# any certificate. Development only.
|
|
@@ -763,7 +875,9 @@ module Skaidb
|
|
|
763
875
|
raise QueryError, "statement expects #{nparams} parameters, got #{order.length}"
|
|
764
876
|
end
|
|
765
877
|
|
|
766
|
-
|
|
878
|
+
res = exec_prepared(id, order, level)
|
|
879
|
+
note_statement(sql)
|
|
880
|
+
return res
|
|
767
881
|
end
|
|
768
882
|
end
|
|
769
883
|
text = begin
|
|
@@ -805,12 +919,10 @@ module Skaidb
|
|
|
805
919
|
req << [b.bytesize].pack("V") << b
|
|
806
920
|
end
|
|
807
921
|
end
|
|
808
|
-
reader =
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
end
|
|
813
|
-
parse_response(reader).cmd_tuples
|
|
922
|
+
reader = request(req)
|
|
923
|
+
n = parse_response(reader).cmd_tuples
|
|
924
|
+
note_statement(sql)
|
|
925
|
+
n
|
|
814
926
|
end
|
|
815
927
|
|
|
816
928
|
# Stream a result set: yields one row Hash at a time while holding a
|
|
@@ -845,6 +957,12 @@ module Skaidb
|
|
|
845
957
|
# the whole stream), which is what keeps it out of the pool. Iterate with
|
|
846
958
|
# a block or +each+ if you mean to reuse the connection.
|
|
847
959
|
#
|
|
960
|
+
# With a block it returns a Result once the stream ends: for a row stream
|
|
961
|
+
# +kind+ is +:stream+ and +fields+ holds the column names (known even when
|
|
962
|
+
# no row arrived) while +rows+ stays empty — the rows went to the block;
|
|
963
|
+
# a statement that produces no rows returns its ordinary +:mutation+ or
|
|
964
|
+
# +:ddl+ Result without yielding.
|
|
965
|
+
#
|
|
848
966
|
# Takes no parameters — the streaming opcode carries SQL text.
|
|
849
967
|
def stream(sql, consistency: nil)
|
|
850
968
|
return enum_for(:stream, sql, consistency: consistency) unless block_given?
|
|
@@ -855,16 +973,27 @@ module Skaidb
|
|
|
855
973
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
856
974
|
req = [5, level].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
857
975
|
live = false
|
|
976
|
+
summary = nil
|
|
858
977
|
@mutex.synchronize do
|
|
859
|
-
|
|
860
|
-
|
|
978
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
979
|
+
begin
|
|
980
|
+
write_frame(req)
|
|
981
|
+
r = Reader.new(read_frame)
|
|
982
|
+
rescue ConnectionError
|
|
983
|
+
observe_failure
|
|
984
|
+
raise
|
|
985
|
+
end
|
|
986
|
+
@observer&.note_round_trip(self, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
861
987
|
tag = r.u8
|
|
988
|
+
note_statement(sql) unless tag == 3
|
|
862
989
|
case tag
|
|
863
990
|
when 3
|
|
864
991
|
msg = r.text
|
|
865
992
|
raise QueryError, msg.include?("unknown opcode") ? "server does not support streaming: #{msg}" : msg
|
|
866
|
-
when 1
|
|
867
|
-
return
|
|
993
|
+
when 1
|
|
994
|
+
return Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation) # not row-producing
|
|
995
|
+
when 2
|
|
996
|
+
return Result.new(fields: [], rows: [], cmd_tuples: 0, kind: :ddl)
|
|
868
997
|
when 5
|
|
869
998
|
# The server has already committed to a row stream, so from here
|
|
870
999
|
# the socket carries frames this call owns — mark it BEFORE
|
|
@@ -879,6 +1008,7 @@ module Skaidb
|
|
|
879
1008
|
live = true
|
|
880
1009
|
@streaming = true
|
|
881
1010
|
cols = Array.new(r.u32) { r.text }
|
|
1011
|
+
summary = Result.new(fields: cols, rows: [], cmd_tuples: 0, kind: :stream)
|
|
882
1012
|
begin
|
|
883
1013
|
while live
|
|
884
1014
|
fr = Reader.new(read_frame)
|
|
@@ -914,7 +1044,7 @@ module Skaidb
|
|
|
914
1044
|
raise QueryError, "unexpected response tag #{tag} to stream request"
|
|
915
1045
|
end
|
|
916
1046
|
end
|
|
917
|
-
|
|
1047
|
+
summary
|
|
918
1048
|
end
|
|
919
1049
|
|
|
920
1050
|
# Prepare +sql+ on the SERVER, returning [id, nparams], or nil when the
|
|
@@ -928,11 +1058,7 @@ module Skaidb
|
|
|
928
1058
|
|
|
929
1059
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
930
1060
|
req = [2].pack("C") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
931
|
-
reader =
|
|
932
|
-
@mutex.synchronize do
|
|
933
|
-
write_frame(req)
|
|
934
|
-
reader = Reader.new(read_frame)
|
|
935
|
-
end
|
|
1061
|
+
reader = request(req)
|
|
936
1062
|
tag = reader.u8
|
|
937
1063
|
case tag
|
|
938
1064
|
when 4
|
|
@@ -960,11 +1086,7 @@ module Skaidb
|
|
|
960
1086
|
v = Skaidb.encode_value(p)
|
|
961
1087
|
req << [v.bytesize].pack("V") << v
|
|
962
1088
|
end
|
|
963
|
-
reader =
|
|
964
|
-
@mutex.synchronize do
|
|
965
|
-
write_frame(req)
|
|
966
|
-
reader = Reader.new(read_frame)
|
|
967
|
-
end
|
|
1089
|
+
reader = request(req)
|
|
968
1090
|
parse_response(reader)
|
|
969
1091
|
end
|
|
970
1092
|
|
|
@@ -1018,6 +1140,9 @@ module Skaidb
|
|
|
1018
1140
|
rescue StandardError
|
|
1019
1141
|
# ignore — socket already gone
|
|
1020
1142
|
end
|
|
1143
|
+
# Closed while checked out instead of returned: the pool stops
|
|
1144
|
+
# counting it against its member.
|
|
1145
|
+
@observer.note_closed(self) if @observer && @pool_out
|
|
1021
1146
|
nil
|
|
1022
1147
|
end
|
|
1023
1148
|
|
|
@@ -1143,9 +1268,7 @@ module Skaidb
|
|
|
1143
1268
|
iterations = r.u32
|
|
1144
1269
|
server_nonce = r.text
|
|
1145
1270
|
|
|
1146
|
-
|
|
1147
|
-
auth_message = [user, client_nonce, server_nonce, salt_hex, iterations.to_s]
|
|
1148
|
-
.join("\0").dup.force_encoding("UTF-8")
|
|
1271
|
+
auth_message = Skaidb.auth_message(user, client_nonce, server_nonce, salt, iterations)
|
|
1149
1272
|
proof, expected_sig = Skaidb.scram(password, salt, iterations, auth_message)
|
|
1150
1273
|
|
|
1151
1274
|
write_frame([12].pack("C") + proof) # 32 raw bytes, not length-prefixed
|
|
@@ -1163,6 +1286,17 @@ module Skaidb
|
|
|
1163
1286
|
end
|
|
1164
1287
|
end
|
|
1165
1288
|
|
|
1289
|
+
# EXTERNAL (PROTOCOL.md §2.4): the TLS client certificate is the
|
|
1290
|
+
# credential, its Common Name the username; +user+ may be empty or must
|
|
1291
|
+
# equal it. AuthStart carries an empty client nonce and mechanism byte 2,
|
|
1292
|
+
# and the server answers AuthOutcome at once. The outcome's 32-byte
|
|
1293
|
+
# signature is NOT verified — there is no shared secret to verify it
|
|
1294
|
+
# with; TLS already authenticated the server.
|
|
1295
|
+
def handshake_certificate(user)
|
|
1296
|
+
write_frame(Skaidb.certificate_auth_start(user))
|
|
1297
|
+
Skaidb.parse_certificate_outcome(read_frame)
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1166
1300
|
# -- query --
|
|
1167
1301
|
|
|
1168
1302
|
def run(sql, consistency)
|
|
@@ -1171,12 +1305,38 @@ module Skaidb
|
|
|
1171
1305
|
|
|
1172
1306
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
1173
1307
|
req = [1, consistency].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
1174
|
-
reader =
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1308
|
+
reader = request(req)
|
|
1309
|
+
res = parse_response(reader)
|
|
1310
|
+
note_statement(sql)
|
|
1311
|
+
res
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
# One request and its response under the connection mutex. In a pool the
|
|
1315
|
+
# round trip (send to the first response frame) feeds the member's
|
|
1316
|
+
# latency average, and a transport failure counts against the member.
|
|
1317
|
+
def request(req)
|
|
1318
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1319
|
+
frame = begin
|
|
1320
|
+
@mutex.synchronize do
|
|
1321
|
+
write_frame(req)
|
|
1322
|
+
read_frame
|
|
1323
|
+
end
|
|
1324
|
+
rescue ConnectionError
|
|
1325
|
+
observe_failure
|
|
1326
|
+
raise
|
|
1178
1327
|
end
|
|
1179
|
-
|
|
1328
|
+
@observer&.note_round_trip(self, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
1329
|
+
Reader.new(frame)
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def observe_failure
|
|
1333
|
+
@observer.note_failure(@endpoint) if @observer && @endpoint
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
# A statement succeeded: a pool with read-your-writes on remembers which
|
|
1337
|
+
# member took a write.
|
|
1338
|
+
def note_statement(sql)
|
|
1339
|
+
@observer&.note_statement(self, sql)
|
|
1180
1340
|
end
|
|
1181
1341
|
|
|
1182
1342
|
def parse_response(r)
|
|
@@ -1205,9 +1365,9 @@ module Skaidb
|
|
|
1205
1365
|
last = sets.last || Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1206
1366
|
Result.new(fields: last.fields, rows: last.rows, cmd_tuples: 0, result_sets: sets)
|
|
1207
1367
|
when 1 # Mutation
|
|
1208
|
-
Result.new(fields: [], rows: [], cmd_tuples: r.u64)
|
|
1368
|
+
Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation)
|
|
1209
1369
|
when 2 # Ddl
|
|
1210
|
-
Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1370
|
+
Result.new(fields: [], rows: [], cmd_tuples: 0, kind: :ddl)
|
|
1211
1371
|
when 3 # Error — a statement error, connection stays usable
|
|
1212
1372
|
raise QueryError, r.text
|
|
1213
1373
|
else
|
|
@@ -1226,18 +1386,16 @@ module Skaidb
|
|
|
1226
1386
|
# @param password [String]
|
|
1227
1387
|
# @param consistency [Symbol, String, Integer] :one / :quorum / :all (or 0/1/2)
|
|
1228
1388
|
# @param timeout [Numeric, nil] connect/IO timeout in seconds
|
|
1389
|
+
# @param tls_client_cert [String, nil] PEM file of a client certificate to
|
|
1390
|
+
# present over TLS (with +tls_client_key+); turns TLS on
|
|
1391
|
+
# @param tls_client_key [String, nil] PEM file of its private key
|
|
1392
|
+
# @param auth_mechanism [Symbol, String] +:scram+ (user + password, the
|
|
1393
|
+
# default) or +:certificate+ (the client certificate is the login; +user+
|
|
1394
|
+
# is optional and, when given, must equal the certificate's Common Name)
|
|
1229
1395
|
# @yield [conn] optional block; the connection is closed when it returns
|
|
1230
1396
|
# @return [Connection] (or the block's value when a block is given)
|
|
1231
|
-
def self.connect(
|
|
1232
|
-
|
|
1233
|
-
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
1234
|
-
tls_server_name: "skaidb", seeds: nil)
|
|
1235
|
-
tls = true if tls_ca || tls_insecure
|
|
1236
|
-
conn = Connection.new(host: host, port: port, user: user,
|
|
1237
|
-
password: password, consistency: consistency,
|
|
1238
|
-
timeout: timeout, database: database, tls: tls,
|
|
1239
|
-
tls_ca: tls_ca, tls_insecure: tls_insecure,
|
|
1240
|
-
tls_server_name: tls_server_name, seeds: seeds)
|
|
1397
|
+
def self.connect(**options)
|
|
1398
|
+
conn = Connection.new(**connection_options(**options))
|
|
1241
1399
|
return conn unless block_given?
|
|
1242
1400
|
|
|
1243
1401
|
begin
|
|
@@ -1246,67 +1404,257 @@ module Skaidb
|
|
|
1246
1404
|
conn.close
|
|
1247
1405
|
end
|
|
1248
1406
|
end
|
|
1249
|
-
|
|
1407
|
+
|
|
1408
|
+
# Internal: Skaidb.connect's keywords with their defaults applied, as
|
|
1409
|
+
# Connection.new takes them (Pool dials every connection with these).
|
|
1410
|
+
def self.connection_options(host: "localhost", port: 7000, user: nil,
|
|
1411
|
+
password: "", consistency: :quorum, timeout: 10.0,
|
|
1412
|
+
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
1413
|
+
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
1414
|
+
tls_client_key: nil, auth_mechanism: :scram)
|
|
1415
|
+
tls = true if tls_ca || tls_insecure || tls_client_cert
|
|
1416
|
+
# A SCRAM login with no user is "anonymous"; a certificate login with no
|
|
1417
|
+
# user sends an empty one and lets the certificate's CN decide.
|
|
1418
|
+
user = "anonymous" if user.nil? && AuthMechanism.resolve(auth_mechanism) == AuthMechanism::SCRAM
|
|
1419
|
+
{ host: host, port: port, user: user, password: password, consistency: consistency,
|
|
1420
|
+
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
1421
|
+
tls_insecure: tls_insecure, tls_server_name: tls_server_name, seeds: seeds,
|
|
1422
|
+
tls_client_cert: tls_client_cert, tls_client_key: tls_client_key,
|
|
1423
|
+
auth_mechanism: auth_mechanism }
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
# Internal: "host:port", or a bare host on +default_port+, as [host, port].
|
|
1427
|
+
def self.parse_endpoint(text, default_port)
|
|
1428
|
+
h, _, p = text.to_s.rpartition(":")
|
|
1429
|
+
h.empty? ? [text.to_s, default_port] : [h, p.to_i]
|
|
1430
|
+
end
|
|
1431
|
+
|
|
1432
|
+
# Leading keywords of statements that never write: everything else counts
|
|
1433
|
+
# as a write for read-your-writes affinity (a read taken for a write only
|
|
1434
|
+
# keeps the caller on its member a little longer).
|
|
1435
|
+
READ_KEYWORDS = %w[select show explain describe desc values with use].freeze
|
|
1436
|
+
# Whitespace, comments and opening parentheses before the first keyword.
|
|
1437
|
+
STATEMENT_LEAD = %r{\A(?:\s+|\(|--[^\n]*\n?|/\*.*?(?:\*/|\z))*}m.freeze
|
|
1438
|
+
private_constant :READ_KEYWORDS, :STATEMENT_LEAD
|
|
1439
|
+
|
|
1440
|
+
# Internal: whether +sql+ counts as a write for read-your-writes — its
|
|
1441
|
+
# first keyword, after whitespace, comments and opening parentheses, is
|
|
1442
|
+
# none of SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, VALUES, WITH, USE.
|
|
1443
|
+
def self.write_statement?(sql)
|
|
1444
|
+
rest = sql.to_s.b.sub(STATEMENT_LEAD, "")
|
|
1445
|
+
!READ_KEYWORDS.include?(rest[/\A[A-Za-z_]*/].downcase)
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# Internal: whether a failed dial says something about the member's
|
|
1449
|
+
# health. A refused login is the caller's problem, not the member's —
|
|
1450
|
+
# except a capacity refusal, which is the member being full.
|
|
1451
|
+
def self.member_fault?(error)
|
|
1452
|
+
return true unless error.is_a?(Error) # socket, TLS, timeout
|
|
1453
|
+
|
|
1454
|
+
msg = error.message
|
|
1455
|
+
return msg.include?("too many connections:") if msg.start_with?("authentication denied")
|
|
1456
|
+
|
|
1457
|
+
!msg.start_with?("server signature mismatch")
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
# A thread-safe pool of connections, balanced across the cluster's members.
|
|
1250
1461
|
#
|
|
1251
1462
|
# +maxsize+ bounds the connections kept IDLE, not the number checked out: a
|
|
1252
1463
|
# burst creates extras and the surplus is closed on return. Every keyword
|
|
1253
|
-
# accepted by Skaidb.connect passes through
|
|
1254
|
-
#
|
|
1464
|
+
# accepted by Skaidb.connect passes through: +host+/+port+/+seeds+ name the
|
|
1465
|
+
# starting members, and the rest (TLS, the session database, ...) apply to
|
|
1466
|
+
# every connection.
|
|
1467
|
+
#
|
|
1468
|
+
# Load balancing (PROTOCOL.md §6.1, the same in every skaidb driver; see
|
|
1469
|
+
# docs/pooling.md):
|
|
1470
|
+
#
|
|
1471
|
+
# - +load_balancing+ — +:random+ (default: each new connection dials the
|
|
1472
|
+
# members in random order), +:round_robin+ (checkouts rotate over the
|
|
1473
|
+
# members) or +:least_latency+ (of two members picked at random, the one
|
|
1474
|
+
# with the lower latency average x checked-out connections).
|
|
1475
|
+
# - +idle_order+ — +:lifo+ (default: the most recently returned idle
|
|
1476
|
+
# connection first) or +:fifo+ (the longest idle first, so every idle
|
|
1477
|
+
# connection keeps getting used).
|
|
1478
|
+
# - +discover+ — read the members from SHOW ENDPOINTS (server >= 0.327)
|
|
1479
|
+
# when a member is first reached and every +refresh_interval+ seconds
|
|
1480
|
+
# after (every 5 s while one is not ready): joiners are added, leavers
|
|
1481
|
+
# dropped, and members reported +resyncing+ or +unreachable+ get no new
|
|
1482
|
+
# connections until they are +ready+ again.
|
|
1483
|
+
# - Health ejection: a member with +eject_errors+ consecutive transport
|
|
1484
|
+
# failures, or whose latency average is above +eject_latency_factor+
|
|
1485
|
+
# times the other members' median and more than +eject_latency_floor+
|
|
1486
|
+
# seconds above it, gets no connections for +eject_backoff+ seconds
|
|
1487
|
+
# (doubling per repeat, up to +eject_backoff_max+); then a background
|
|
1488
|
+
# thread probes it and re-admits it. The last usable member is never
|
|
1489
|
+
# ejected.
|
|
1490
|
+
# - +read_your_writes+ — seconds after a write during which checkouts with
|
|
1491
|
+
# the same +affinity:+ key go to the member that took it (the coordinator
|
|
1492
|
+
# that acknowledged a write reads it back even at ONE). 0 (default) = off.
|
|
1493
|
+
# - +logger+ — a Logger (or anything with +warn+) told of ejections and
|
|
1494
|
+
# re-admissions.
|
|
1255
1495
|
#
|
|
1256
|
-
# pool = Skaidb::Pool.new(seeds: ["h1:7000", "h2:7000"], database: "app", maxsize: 8
|
|
1496
|
+
# pool = Skaidb::Pool.new(seeds: ["h1:7000", "h2:7000"], database: "app", maxsize: 8,
|
|
1497
|
+
# load_balancing: :least_latency, discover: true)
|
|
1257
1498
|
# pool.with { |conn| conn.exec("SELECT 1") }
|
|
1258
1499
|
# pool.close
|
|
1259
1500
|
class Pool
|
|
1260
|
-
|
|
1501
|
+
POLICIES = %i[random round_robin least_latency].freeze
|
|
1502
|
+
IDLE_ORDERS = %i[lifo fifo].freeze
|
|
1503
|
+
# Latency EWMA: each round trip moves a member's average 5% toward it,
|
|
1504
|
+
# and no sample counts for more than 10x the larger of the current
|
|
1505
|
+
# average and the ejection floor — one slow query does not eject a
|
|
1506
|
+
# healthy member, a member that stays slow is ejected within a few dozen
|
|
1507
|
+
# statements.
|
|
1508
|
+
EWMA_WEIGHT = 0.05
|
|
1509
|
+
EWMA_CLAMP = 10.0
|
|
1510
|
+
# Round trips a member (and at least one other) needs before its average
|
|
1511
|
+
# can eject it.
|
|
1512
|
+
EWMA_MIN_SAMPLES = 10
|
|
1513
|
+
# least_latency: a member whose last round trip is older than this scores
|
|
1514
|
+
# 0, so a lightly loaded pool keeps re-measuring every member instead of
|
|
1515
|
+
# settling forever on whichever answered fastest once.
|
|
1516
|
+
STALE_SAMPLE = 2.0
|
|
1517
|
+
# Discovery refresh while a listed member is not ready: a member is often
|
|
1518
|
+
# +unreachable+ or +resyncing+ only for seconds (a peer's circuit breaker
|
|
1519
|
+
# right after it restarts), and waiting a whole refresh interval to let
|
|
1520
|
+
# it back in leaves the load on the others.
|
|
1521
|
+
REFRESH_NOT_READY = 5.0
|
|
1522
|
+
# How often the background thread checks for members due a re-probe.
|
|
1523
|
+
MAINTAIN_TICK = 0.25
|
|
1524
|
+
# Read-your-writes keys kept before the expired ones are swept.
|
|
1525
|
+
AFFINITY_SWEEP = 4096
|
|
1526
|
+
private_constant :EWMA_WEIGHT, :EWMA_CLAMP, :EWMA_MIN_SAMPLES, :STALE_SAMPLE,
|
|
1527
|
+
:REFRESH_NOT_READY, :MAINTAIN_TICK, :AFFINITY_SWEEP
|
|
1528
|
+
|
|
1529
|
+
# One member as the pool sees it.
|
|
1530
|
+
class Member
|
|
1531
|
+
attr_reader :endpoint, :host, :port, :seed
|
|
1532
|
+
attr_accessor :canonical, :listed, :ewma, :samples, :last_sample, :errors,
|
|
1533
|
+
:ejected_until, :ejections, :readmitted_at, :in_use, :probing
|
|
1534
|
+
|
|
1535
|
+
def initialize(host, port, seed)
|
|
1536
|
+
@host = host
|
|
1537
|
+
@port = port
|
|
1538
|
+
@endpoint = "#{host}:#{port}" # what the pool dials, and its key
|
|
1539
|
+
@seed = seed # configured: discovery never drops it
|
|
1540
|
+
@canonical = nil # its own SHOW ENDPOINTS name
|
|
1541
|
+
@listed = :ready # :ready, :resyncing, :unreachable, :unlisted
|
|
1542
|
+
@ewma = nil # seconds
|
|
1543
|
+
@samples = 0
|
|
1544
|
+
@last_sample = 0.0
|
|
1545
|
+
@errors = 0 # consecutive transport failures
|
|
1546
|
+
@ejected_until = nil # nil = admitted
|
|
1547
|
+
@ejections = 0
|
|
1548
|
+
@readmitted_at = 0.0
|
|
1549
|
+
@in_use = 0
|
|
1550
|
+
@probing = false
|
|
1551
|
+
end
|
|
1552
|
+
end
|
|
1553
|
+
private_constant :Member
|
|
1554
|
+
|
|
1555
|
+
def initialize(maxsize: 10, load_balancing: :random, idle_order: :lifo, discover: false,
|
|
1556
|
+
refresh_interval: 60.0, read_your_writes: 0.0, eject_errors: 3,
|
|
1557
|
+
eject_latency_factor: 3.0, eject_latency_floor: 0.05, eject_backoff: 5.0,
|
|
1558
|
+
eject_backoff_max: 60.0, logger: nil, **connect_kwargs)
|
|
1261
1559
|
raise ArgumentError, "maxsize must be >= 1" if maxsize < 1
|
|
1560
|
+
raise ArgumentError, "eject_errors must be >= 1" if eject_errors < 1
|
|
1262
1561
|
|
|
1562
|
+
@policy = choice(load_balancing, POLICIES, "load_balancing")
|
|
1563
|
+
@fifo = choice(idle_order, IDLE_ORDERS, "idle_order") == :fifo
|
|
1263
1564
|
@maxsize = maxsize
|
|
1264
|
-
@
|
|
1565
|
+
@discover = discover ? true : false
|
|
1566
|
+
@refresh_interval = refresh_interval.to_f
|
|
1567
|
+
@ryw = read_your_writes.to_f
|
|
1568
|
+
@eject_errors = eject_errors
|
|
1569
|
+
@eject_factor = eject_latency_factor.to_f
|
|
1570
|
+
@eject_floor = eject_latency_floor.to_f
|
|
1571
|
+
@backoff = eject_backoff.to_f
|
|
1572
|
+
@backoff_max = eject_backoff_max.to_f
|
|
1573
|
+
@logger = logger
|
|
1574
|
+
@conn_opts = Skaidb.connection_options(**connect_kwargs)
|
|
1575
|
+
@port = @conn_opts[:port]
|
|
1576
|
+
seeds = @conn_opts[:seeds]
|
|
1577
|
+
@members = {}
|
|
1578
|
+
(seeds && !seeds.empty? ? seeds : ["#{@conn_opts[:host]}:#{@port}"]).each do |s|
|
|
1579
|
+
host, port = Skaidb.parse_endpoint(s, @port)
|
|
1580
|
+
@members["#{host}:#{port}"] ||= Member.new(host, port, true)
|
|
1581
|
+
end
|
|
1265
1582
|
@idle = []
|
|
1266
1583
|
@mutex = Mutex.new
|
|
1584
|
+
@wake = ConditionVariable.new
|
|
1585
|
+
@rr = -1
|
|
1586
|
+
@affinity = {}
|
|
1587
|
+
@discovery_off = false
|
|
1588
|
+
@next_refresh = 0.0
|
|
1589
|
+
@maintainer = nil
|
|
1267
1590
|
@closed = false
|
|
1268
1591
|
end
|
|
1269
1592
|
|
|
1270
|
-
#
|
|
1271
|
-
|
|
1593
|
+
# -- checkout / checkin --
|
|
1594
|
+
|
|
1595
|
+
# Check out a usable connection: an idle one to the member the policy
|
|
1596
|
+
# picks, or a new one dialed to it (falling back to the other members).
|
|
1597
|
+
# +affinity+ scopes read-your-writes (nil = pool-wide).
|
|
1598
|
+
def checkout(affinity: nil)
|
|
1272
1599
|
loop do
|
|
1273
|
-
conn =
|
|
1600
|
+
conn = order = nil
|
|
1601
|
+
stale = []
|
|
1602
|
+
@mutex.synchronize do
|
|
1274
1603
|
raise Error, "pool is closed" if @closed
|
|
1275
1604
|
|
|
1276
|
-
|
|
1605
|
+
target = pick(affinity)
|
|
1606
|
+
conn, stale = take_idle(target)
|
|
1607
|
+
order = order_for(target) if conn.nil?
|
|
1277
1608
|
end
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1609
|
+
stale.each { |c| close_quietly(c) }
|
|
1610
|
+
if conn.nil?
|
|
1611
|
+
conn = Connection.new(**@conn_opts, endpoints: order, observer: self)
|
|
1612
|
+
conn.pool_member = conn.endpoint
|
|
1613
|
+
discover_through(conn)
|
|
1614
|
+
unless conn.usable?
|
|
1615
|
+
close_quietly(conn)
|
|
1616
|
+
next
|
|
1617
|
+
end
|
|
1618
|
+
elsif !conn.usable?
|
|
1619
|
+
# A connection the server closed while it sat idle still looks
|
|
1620
|
+
# fine locally, so check before handing it out.
|
|
1621
|
+
close_quietly(conn)
|
|
1622
|
+
next
|
|
1287
1623
|
end
|
|
1624
|
+
@mutex.synchronize do
|
|
1625
|
+
m = @members[conn.pool_member]
|
|
1626
|
+
m.in_use += 1 if m
|
|
1627
|
+
conn.pool_affinity = affinity
|
|
1628
|
+
conn.pool_out = true
|
|
1629
|
+
end
|
|
1630
|
+
return conn
|
|
1288
1631
|
end
|
|
1289
1632
|
end
|
|
1290
1633
|
|
|
1291
|
-
# Return a connection, closing it if broken
|
|
1634
|
+
# Return a connection, closing it if it is broken, its member is ejected
|
|
1635
|
+
# or gone, or the pool is full.
|
|
1292
1636
|
def checkin(conn)
|
|
1293
1637
|
keep = @mutex.synchronize do
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1638
|
+
if conn.pool_out
|
|
1639
|
+
conn.pool_out = false
|
|
1640
|
+
counted = @members[conn.pool_member]
|
|
1641
|
+
counted.in_use -= 1 if counted && counted.in_use.positive?
|
|
1642
|
+
end
|
|
1643
|
+
m = @members[conn.pool_member]
|
|
1644
|
+
if !@closed && conn.usable? && m && usable?(m) && @idle.length < @maxsize
|
|
1645
|
+
@idle.push(conn)
|
|
1646
|
+
true
|
|
1647
|
+
else
|
|
1648
|
+
false
|
|
1649
|
+
end
|
|
1304
1650
|
end
|
|
1651
|
+
close_quietly(conn) unless keep
|
|
1305
1652
|
end
|
|
1306
1653
|
|
|
1307
1654
|
# Run the block with a checked-out connection, returning it afterwards.
|
|
1308
|
-
|
|
1309
|
-
|
|
1655
|
+
# +affinity+ scopes read-your-writes (nil = pool-wide).
|
|
1656
|
+
def with(affinity: nil)
|
|
1657
|
+
conn = checkout(affinity: affinity)
|
|
1310
1658
|
begin
|
|
1311
1659
|
yield conn
|
|
1312
1660
|
ensure
|
|
@@ -1314,21 +1662,401 @@ module Skaidb
|
|
|
1314
1662
|
end
|
|
1315
1663
|
end
|
|
1316
1664
|
|
|
1317
|
-
#
|
|
1665
|
+
# The members as the pool sees them, for monitoring: one Hash each with
|
|
1666
|
+
# +:endpoint+ (what the pool dials), +:canonical+ (the member's own name,
|
|
1667
|
+
# once discovered), +:state+ (+:ready+, +:ejected+, +:resyncing+,
|
|
1668
|
+
# +:unreachable+, +:unlisted+), +:latency_ms+ (the round-trip average,
|
|
1669
|
+
# nil until measured), +:in_use+, +:idle+ and +:seed+.
|
|
1670
|
+
def members
|
|
1671
|
+
@mutex.synchronize do
|
|
1672
|
+
idle = Hash.new(0)
|
|
1673
|
+
@idle.each { |c| idle[c.pool_member] += 1 }
|
|
1674
|
+
@members.values.sort_by { |m| [m.host, m.port] }.map do |m|
|
|
1675
|
+
{ endpoint: m.endpoint, canonical: m.canonical,
|
|
1676
|
+
state: m.ejected_until ? :ejected : m.listed,
|
|
1677
|
+
latency_ms: m.ewma && m.ewma * 1000.0, in_use: m.in_use,
|
|
1678
|
+
idle: idle[m.endpoint], seed: m.seed }
|
|
1679
|
+
end
|
|
1680
|
+
end
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
# Close the pool and every idle connection, and stop the background
|
|
1684
|
+
# thread. Checked-out connections are closed when they are returned.
|
|
1318
1685
|
def close
|
|
1319
1686
|
drained = @mutex.synchronize do
|
|
1320
1687
|
@closed = true
|
|
1688
|
+
@wake.broadcast
|
|
1321
1689
|
d = @idle
|
|
1322
1690
|
@idle = []
|
|
1323
1691
|
d
|
|
1324
1692
|
end
|
|
1325
|
-
drained.each
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1693
|
+
drained.each { |c| close_quietly(c) }
|
|
1694
|
+
end
|
|
1695
|
+
|
|
1696
|
+
# -- what connections report (internal: called by Connection, any thread) --
|
|
1697
|
+
|
|
1698
|
+
# Internal: the order a pooled connection re-dials in — the usable
|
|
1699
|
+
# members first, +exclude+ (the member it lost) last.
|
|
1700
|
+
def dial_order(exclude)
|
|
1701
|
+
@mutex.synchronize { order_for(nil, exclude) }
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
# Internal: a round trip on +conn+ took +elapsed+ seconds.
|
|
1705
|
+
def note_round_trip(conn, elapsed)
|
|
1706
|
+
@mutex.synchronize do
|
|
1707
|
+
ep = conn.endpoint
|
|
1708
|
+
if ep != conn.pool_member
|
|
1709
|
+
# The connection re-dialed to another member mid-checkout.
|
|
1710
|
+
if conn.pool_out
|
|
1711
|
+
old = @members[conn.pool_member]
|
|
1712
|
+
old.in_use -= 1 if old && old.in_use.positive?
|
|
1713
|
+
moved = @members[ep]
|
|
1714
|
+
moved.in_use += 1 if moved
|
|
1715
|
+
end
|
|
1716
|
+
conn.pool_member = ep
|
|
1717
|
+
end
|
|
1718
|
+
m = @members[ep]
|
|
1719
|
+
next if m.nil?
|
|
1720
|
+
|
|
1721
|
+
m.errors = 0
|
|
1722
|
+
if m.ewma.nil?
|
|
1723
|
+
m.ewma = elapsed
|
|
1724
|
+
else
|
|
1725
|
+
cap = EWMA_CLAMP * [m.ewma, @eject_floor].max
|
|
1726
|
+
m.ewma += EWMA_WEIGHT * ([elapsed, cap].min - m.ewma)
|
|
1727
|
+
end
|
|
1728
|
+
m.samples += 1
|
|
1729
|
+
m.last_sample = now
|
|
1730
|
+
next unless m.samples >= EWMA_MIN_SAMPLES && @eject_factor.positive? && m.ejected_until.nil?
|
|
1731
|
+
|
|
1732
|
+
others = @members.each_value.select do |o|
|
|
1733
|
+
!o.equal?(m) && usable?(o) && o.ewma && o.samples >= EWMA_MIN_SAMPLES
|
|
1734
|
+
end.map(&:ewma).sort
|
|
1735
|
+
next if others.empty?
|
|
1736
|
+
|
|
1737
|
+
median = others[(others.length - 1) / 2]
|
|
1738
|
+
if m.ewma > @eject_factor * median && m.ewma - median > @eject_floor
|
|
1739
|
+
eject(m, format("latency %.1f ms vs median %.1f ms", m.ewma * 1000, median * 1000))
|
|
1330
1740
|
end
|
|
1331
1741
|
end
|
|
1332
1742
|
end
|
|
1743
|
+
|
|
1744
|
+
# Internal: a dial or a request to +endpoint+ failed in transport.
|
|
1745
|
+
def note_failure(endpoint)
|
|
1746
|
+
@mutex.synchronize do
|
|
1747
|
+
m = @members[endpoint]
|
|
1748
|
+
next if m.nil?
|
|
1749
|
+
|
|
1750
|
+
m.errors += 1
|
|
1751
|
+
eject(m, "#{m.errors} consecutive connection failures") if m.errors >= @eject_errors && m.ejected_until.nil?
|
|
1752
|
+
end
|
|
1753
|
+
end
|
|
1754
|
+
|
|
1755
|
+
# Internal: +sql+ succeeded on +conn+; a write pins the connection's
|
|
1756
|
+
# affinity key to its member for +read_your_writes+ seconds.
|
|
1757
|
+
def note_statement(conn, sql)
|
|
1758
|
+
return if @ryw <= 0 || !conn.pool_out || !Skaidb.write_statement?(sql)
|
|
1759
|
+
|
|
1760
|
+
t = now
|
|
1761
|
+
@mutex.synchronize do
|
|
1762
|
+
@affinity.select! { |_, (_, untl)| untl > t } if @affinity.length >= AFFINITY_SWEEP
|
|
1763
|
+
@affinity[conn.pool_affinity] = [conn.endpoint, t + @ryw]
|
|
1764
|
+
end
|
|
1765
|
+
end
|
|
1766
|
+
|
|
1767
|
+
# Internal: +conn+ was closed while checked out instead of returned.
|
|
1768
|
+
def note_closed(conn)
|
|
1769
|
+
@mutex.synchronize do
|
|
1770
|
+
next unless conn.pool_out
|
|
1771
|
+
|
|
1772
|
+
conn.pool_out = false
|
|
1773
|
+
m = @members[conn.pool_member]
|
|
1774
|
+
m.in_use -= 1 if m && m.in_use.positive?
|
|
1775
|
+
end
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
private
|
|
1779
|
+
|
|
1780
|
+
def choice(value, allowed, name)
|
|
1781
|
+
v = value.to_s.downcase.to_sym if value.is_a?(String) || value.is_a?(Symbol)
|
|
1782
|
+
return v if allowed.include?(v)
|
|
1783
|
+
|
|
1784
|
+
names = allowed.map(&:inspect)
|
|
1785
|
+
raise ArgumentError, "unknown #{name} #{value.inspect} (use #{names[0..-2].join(', ')} or #{names[-1]})"
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
def now
|
|
1789
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1790
|
+
end
|
|
1791
|
+
|
|
1792
|
+
def close_quietly(conn)
|
|
1793
|
+
conn.close
|
|
1794
|
+
rescue StandardError
|
|
1795
|
+
nil
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
def log(message)
|
|
1799
|
+
@logger&.warn("skaidb pool: #{message}")
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
# -- choosing a member (callers hold @mutex) --
|
|
1803
|
+
|
|
1804
|
+
def usable?(m)
|
|
1805
|
+
m.ejected_until.nil? && m.listed == :ready
|
|
1806
|
+
end
|
|
1807
|
+
|
|
1808
|
+
# The member this checkout should go to, or nil for "any" (the random
|
|
1809
|
+
# policy, or no usable member at all).
|
|
1810
|
+
def pick(affinity)
|
|
1811
|
+
if @ryw.positive? && (hit = @affinity[affinity])
|
|
1812
|
+
ep, untl = hit
|
|
1813
|
+
m = @members[ep]
|
|
1814
|
+
if now >= untl || m.nil?
|
|
1815
|
+
@affinity.delete(affinity)
|
|
1816
|
+
elsif usable?(m)
|
|
1817
|
+
return m
|
|
1818
|
+
end
|
|
1819
|
+
end
|
|
1820
|
+
usable = @members.values.select { |m| usable?(m) }
|
|
1821
|
+
return nil if usable.empty? || @policy == :random
|
|
1822
|
+
|
|
1823
|
+
if @policy == :round_robin
|
|
1824
|
+
usable.sort_by! { |m| [m.host, m.port] }
|
|
1825
|
+
@rr = (@rr + 1) % usable.length
|
|
1826
|
+
return usable[@rr]
|
|
1827
|
+
end
|
|
1828
|
+
return usable[0] if usable.length == 1
|
|
1829
|
+
|
|
1830
|
+
a, b = usable.sample(2)
|
|
1831
|
+
score(a) <= score(b) ? a : b
|
|
1832
|
+
end
|
|
1833
|
+
|
|
1834
|
+
# A member never measured, or not lately, scores 0: it is tried and
|
|
1835
|
+
# measured.
|
|
1836
|
+
def score(m)
|
|
1837
|
+
return 0.0 if m.ewma.nil? || now - m.last_sample > STALE_SAMPLE
|
|
1838
|
+
|
|
1839
|
+
m.ewma * (m.in_use + 1)
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
# Remove and return an idle connection (to +target+, or to any usable
|
|
1843
|
+
# member), plus the idle connections found to belong to members that are
|
|
1844
|
+
# no longer usable (to close outside the lock).
|
|
1845
|
+
def take_idle(target)
|
|
1846
|
+
stale = []
|
|
1847
|
+
found = nil
|
|
1848
|
+
indexes = @fifo ? (0...@idle.length) : (@idle.length - 1).downto(0)
|
|
1849
|
+
indexes.each do |i|
|
|
1850
|
+
m = @members[@idle[i].pool_member]
|
|
1851
|
+
if m.nil? || !usable?(m)
|
|
1852
|
+
stale << i
|
|
1853
|
+
elsif target.nil? || m.equal?(target)
|
|
1854
|
+
found = i
|
|
1855
|
+
break
|
|
1856
|
+
end
|
|
1857
|
+
end
|
|
1858
|
+
picked = found && @idle[found]
|
|
1859
|
+
stale_conns = stale.map { |i| @idle[i] }
|
|
1860
|
+
(found ? stale + [found] : stale).sort.reverse_each { |i| @idle.delete_at(i) }
|
|
1861
|
+
[picked, stale_conns]
|
|
1862
|
+
end
|
|
1863
|
+
|
|
1864
|
+
# [host, port] pairs to dial, best first: +first+, the other usable
|
|
1865
|
+
# members in random order, then the rest (resyncing/unreachable before
|
|
1866
|
+
# ejected, soonest re-admission first) — a dial never has nowhere to go
|
|
1867
|
+
# while any member is configured. +exclude+ goes last.
|
|
1868
|
+
def order_for(first, exclude = nil)
|
|
1869
|
+
all = @members.values
|
|
1870
|
+
usable = all.select { |m| !m.equal?(first) && usable?(m) }.shuffle
|
|
1871
|
+
rest = all.select { |m| !m.equal?(first) && !usable?(m) }
|
|
1872
|
+
rest = rest.sort_by { |m| m.ejected_until ? [1, m.ejected_until] : [0, 0.0] }
|
|
1873
|
+
order = (first ? [first] : []) + usable + rest
|
|
1874
|
+
lost = exclude && order.length > 1 && order.find { |m| m.endpoint == exclude }
|
|
1875
|
+
order = order.reject { |m| m.equal?(lost) } + [lost] if lost
|
|
1876
|
+
order.map { |m| [m.host, m.port] }
|
|
1877
|
+
end
|
|
1878
|
+
|
|
1879
|
+
# -- ejection and re-admission --
|
|
1880
|
+
|
|
1881
|
+
# Take +m+ out of rotation (caller holds @mutex) — unless it is the last
|
|
1882
|
+
# usable member: a slow or flaky member beats none.
|
|
1883
|
+
def eject(m, why)
|
|
1884
|
+
return unless @members.each_value.any? { |o| !o.equal?(m) && usable?(o) }
|
|
1885
|
+
|
|
1886
|
+
t = now
|
|
1887
|
+
m.ejections = 0 if t - m.readmitted_at > @backoff_max # healthy for a while: start over
|
|
1888
|
+
m.ejections += 1
|
|
1889
|
+
m.ejected_until = t + backoff_for(m)
|
|
1890
|
+
log("ejecting #{m.endpoint} (#{why})")
|
|
1891
|
+
ensure_maintainer
|
|
1892
|
+
end
|
|
1893
|
+
|
|
1894
|
+
def backoff_for(m)
|
|
1895
|
+
[@backoff * (2**[m.ejections - 1, 0].max), @backoff_max].min
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
# Start the background thread (caller holds @mutex): it re-probes ejected
|
|
1899
|
+
# members and refreshes discovery, and ends when the pool closes. Ruby
|
|
1900
|
+
# never waits for it at exit.
|
|
1901
|
+
def ensure_maintainer
|
|
1902
|
+
return if @maintainer || @closed
|
|
1903
|
+
|
|
1904
|
+
@maintainer = Thread.new { maintain }
|
|
1905
|
+
@maintainer.name = "skaidb-pool"
|
|
1906
|
+
end
|
|
1907
|
+
|
|
1908
|
+
def maintain
|
|
1909
|
+
loop do
|
|
1910
|
+
due = []
|
|
1911
|
+
refresh = false
|
|
1912
|
+
@mutex.synchronize do
|
|
1913
|
+
@wake.wait(@mutex, MAINTAIN_TICK) unless @closed
|
|
1914
|
+
return if @closed
|
|
1915
|
+
|
|
1916
|
+
t = now
|
|
1917
|
+
due = @members.each_value.select { |m| m.ejected_until && t >= m.ejected_until && !m.probing }
|
|
1918
|
+
due.each { |m| m.probing = true }
|
|
1919
|
+
refresh = @discover && !@discovery_off && t >= @next_refresh
|
|
1920
|
+
end
|
|
1921
|
+
due.each { |m| probe(m) }
|
|
1922
|
+
refresh_members if refresh
|
|
1923
|
+
end
|
|
1924
|
+
end
|
|
1925
|
+
|
|
1926
|
+
# Dial +m+ alone and run one statement: success re-admits it (the probe
|
|
1927
|
+
# connection joins the idle set), failure ejects it again for twice as
|
|
1928
|
+
# long.
|
|
1929
|
+
def probe(m)
|
|
1930
|
+
conn = nil
|
|
1931
|
+
ok = begin
|
|
1932
|
+
conn = Connection.new(**@conn_opts, endpoints: [[m.host, m.port]])
|
|
1933
|
+
conn.exec("SHOW DATABASES")
|
|
1934
|
+
true
|
|
1935
|
+
rescue StandardError
|
|
1936
|
+
false
|
|
1937
|
+
end
|
|
1938
|
+
keep = false
|
|
1939
|
+
@mutex.synchronize do
|
|
1940
|
+
m.probing = false
|
|
1941
|
+
if ok && !@closed
|
|
1942
|
+
m.ejected_until = nil
|
|
1943
|
+
m.errors = 0
|
|
1944
|
+
m.ewma = nil
|
|
1945
|
+
m.samples = 0
|
|
1946
|
+
m.readmitted_at = now
|
|
1947
|
+
log("re-admitted #{m.endpoint}")
|
|
1948
|
+
if @idle.length < @maxsize
|
|
1949
|
+
conn.observer = self
|
|
1950
|
+
conn.pool_member = m.endpoint
|
|
1951
|
+
@idle.push(conn)
|
|
1952
|
+
keep = true
|
|
1953
|
+
end
|
|
1954
|
+
else
|
|
1955
|
+
m.ejections += 1
|
|
1956
|
+
m.ejected_until = now + backoff_for(m)
|
|
1957
|
+
end
|
|
1958
|
+
end
|
|
1959
|
+
close_quietly(conn) if conn && !keep
|
|
1960
|
+
end
|
|
1961
|
+
|
|
1962
|
+
# -- discovery --
|
|
1963
|
+
|
|
1964
|
+
# Run discovery on a freshly dialed connection when the member it reached
|
|
1965
|
+
# has not named itself yet (once per member, so seeds given as host names
|
|
1966
|
+
# are matched to the endpoints the server reports).
|
|
1967
|
+
def discover_through(conn)
|
|
1968
|
+
return if !@discover || @discovery_off
|
|
1969
|
+
|
|
1970
|
+
ask = @mutex.synchronize do
|
|
1971
|
+
m = @members[conn.pool_member]
|
|
1972
|
+
next false if m.nil? || m.canonical
|
|
1973
|
+
|
|
1974
|
+
ensure_maintainer
|
|
1975
|
+
true
|
|
1976
|
+
end
|
|
1977
|
+
list_through(conn) if ask
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
def refresh_members
|
|
1981
|
+
conn = begin
|
|
1982
|
+
checkout
|
|
1983
|
+
rescue StandardError
|
|
1984
|
+
@mutex.synchronize { @next_refresh = now + @refresh_interval }
|
|
1985
|
+
return
|
|
1986
|
+
end
|
|
1987
|
+
begin
|
|
1988
|
+
list_through(conn)
|
|
1989
|
+
ensure
|
|
1990
|
+
checkin(conn)
|
|
1991
|
+
end
|
|
1992
|
+
end
|
|
1993
|
+
|
|
1994
|
+
def list_through(conn)
|
|
1995
|
+
rows = begin
|
|
1996
|
+
conn.exec("SHOW ENDPOINTS").rows
|
|
1997
|
+
rescue QueryError
|
|
1998
|
+
# A server that predates the statement: keep the seeds.
|
|
1999
|
+
@mutex.synchronize { @discovery_off = true }
|
|
2000
|
+
return
|
|
2001
|
+
rescue Error
|
|
2002
|
+
return
|
|
2003
|
+
end
|
|
2004
|
+
if rows.empty?
|
|
2005
|
+
# A standalone server: nothing to discover, ever.
|
|
2006
|
+
@mutex.synchronize { @discovery_off = true }
|
|
2007
|
+
return
|
|
2008
|
+
end
|
|
2009
|
+
@mutex.synchronize do
|
|
2010
|
+
apply_listing(conn.pool_member, rows)
|
|
2011
|
+
interval = @refresh_interval
|
|
2012
|
+
if @members.each_value.any? { |m| m.listed == :resyncing || m.listed == :unreachable }
|
|
2013
|
+
interval = [interval, REFRESH_NOT_READY].min
|
|
2014
|
+
end
|
|
2015
|
+
@next_refresh = now + interval
|
|
2016
|
+
end
|
|
2017
|
+
end
|
|
2018
|
+
|
|
2019
|
+
# Fold a non-empty SHOW ENDPOINTS answer (caller holds @mutex).
|
|
2020
|
+
def apply_listing(dialed, rows)
|
|
2021
|
+
listed = {}
|
|
2022
|
+
rows.each do |endpoint, state, local|
|
|
2023
|
+
listed[endpoint] = state
|
|
2024
|
+
next unless local
|
|
2025
|
+
|
|
2026
|
+
me = @members[dialed]
|
|
2027
|
+
me.canonical = endpoint if me
|
|
2028
|
+
end
|
|
2029
|
+
# A member reported under the name of one already known (a seed given
|
|
2030
|
+
# as a host name) is that member, not a new one.
|
|
2031
|
+
by_name = {}
|
|
2032
|
+
@members.each_value do |m|
|
|
2033
|
+
by_name[m.canonical] = m if m.canonical && (!by_name.key?(m.canonical) || m.seed)
|
|
2034
|
+
end
|
|
2035
|
+
@members.delete_if { |_, m| m.canonical && !by_name[m.canonical].equal?(m) } # a duplicate of a seed
|
|
2036
|
+
seen = {}.compare_by_identity
|
|
2037
|
+
listed.each do |endpoint, state|
|
|
2038
|
+
m = by_name[endpoint]
|
|
2039
|
+
if m.nil?
|
|
2040
|
+
host, port = Skaidb.parse_endpoint(endpoint, @port)
|
|
2041
|
+
m = (@members["#{host}:#{port}"] ||= Member.new(host, port, false))
|
|
2042
|
+
m.canonical = endpoint
|
|
2043
|
+
end
|
|
2044
|
+
state = %w[ready resyncing unreachable].include?(state) ? state.to_sym : :ready
|
|
2045
|
+
if state == :ready && m.listed != :ready
|
|
2046
|
+
# Back in rotation: measured afresh, like a re-admission.
|
|
2047
|
+
m.ewma = nil
|
|
2048
|
+
m.samples = 0
|
|
2049
|
+
m.errors = 0
|
|
2050
|
+
end
|
|
2051
|
+
m.listed = state
|
|
2052
|
+
seen[m] = true
|
|
2053
|
+
end
|
|
2054
|
+
@members.delete_if do |_, m|
|
|
2055
|
+
next false if seen[m] || (m.seed && m.canonical.nil?) # listed, or a seed not identified yet
|
|
2056
|
+
|
|
2057
|
+
m.listed = :unlisted if m.seed # configured, but no longer a member
|
|
2058
|
+
!m.seed
|
|
2059
|
+
end
|
|
2060
|
+
end
|
|
1333
2061
|
end
|
|
1334
2062
|
end
|