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.
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.3"
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 = OpenSSL::KDF.pbkdf2_hmac(
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
- def initialize(fields:, rows:, cmd_tuples: 0, result_sets: [])
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
- endpoints = (seeds && !seeds.empty? ? seeds : ["#{host}:#{port}"]).map do |sd|
661
- h, _, p = sd.to_s.rpartition(":")
662
- h.empty? ? [sd.to_s, port] : [h, p.to_i]
663
- end.shuffle
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
- @sock = tls_wrap(@sock, tls_ca, tls_insecure, tls_server_name) if tls
670
- handshake(user.to_s, password.to_s)
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
- def tls_wrap(sock, ca_file, insecure, server_name)
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
- return exec_prepared(id, order, level)
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 = nil
809
- @mutex.synchronize do
810
- write_frame(req)
811
- reader = Reader.new(read_frame)
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
- write_frame(req)
860
- r = Reader.new(read_frame)
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, 2
867
- return nil # not row-producing
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
- nil
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 = nil
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 = nil
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
- salt_hex = salt.unpack1("H*") # lowercase hex
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 = nil
1175
- @mutex.synchronize do
1176
- write_frame(req)
1177
- reader = Reader.new(read_frame)
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
- parse_response(reader)
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(host: "localhost", port: 7000, user: "anonymous",
1232
- password: "", consistency: :quorum, timeout: 10.0,
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
- # A thread-safe pool of connections.
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, so pooled connections inherit
1254
- # seed failover, TLS and the session database.
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
- def initialize(maxsize: 10, **connect_kwargs)
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
- @kwargs = connect_kwargs
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
- # Check out a usable connection, reusing an idle one when possible.
1271
- def checkout
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 = @mutex.synchronize do
1600
+ conn = order = nil
1601
+ stale = []
1602
+ @mutex.synchronize do
1274
1603
  raise Error, "pool is closed" if @closed
1275
1604
 
1276
- @idle.pop
1605
+ target = pick(affinity)
1606
+ conn, stale = take_idle(target)
1607
+ order = order_for(target) if conn.nil?
1277
1608
  end
1278
- return Skaidb.connect(**@kwargs) if conn.nil?
1279
- # A connection the server closed while it sat idle still looks fine
1280
- # locally, so check before handing it out.
1281
- return conn if conn.usable?
1282
-
1283
- begin
1284
- conn.close
1285
- rescue StandardError
1286
- nil
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 or the pool is full.
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
- !@closed && conn.usable? && @idle.length < @maxsize
1295
- end
1296
- if keep
1297
- @mutex.synchronize { @idle.push(conn) }
1298
- return
1299
- end
1300
- begin
1301
- conn.close
1302
- rescue StandardError
1303
- nil
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
- def with
1309
- conn = checkout
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
- # Close the pool and every idle connection.
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 do |c|
1326
- begin
1327
- c.close
1328
- rescue StandardError
1329
- nil
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