skaidb 1.1.0 → 1.3.0

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