bzync-nextsql 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +37 -0
- data/lib/nextsql/client.rb +595 -0
- data/lib/nextsql/cluster.rb +190 -0
- data/lib/nextsql/errors.rb +18 -0
- data/lib/nextsql/protocol.rb +1126 -0
- data/lib/nextsql/version.rb +5 -0
- data/lib/nextsql.rb +68 -0
- metadata +53 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Cluster is a routing client over every node of a NextSQL HA cluster.
|
|
4
|
+
#
|
|
5
|
+
# With +Config#read_consistency+ set to READ_BOUNDED or READ_STALE it sends
|
|
6
|
+
# eligible read-only statements to a healthy follower (round-robin, falling
|
|
7
|
+
# back to the leader) and everything else — writes, DDL, transaction
|
|
8
|
+
# control, and STRONG reads — to the leader. With the default STRONG
|
|
9
|
+
# consistency every statement goes to the leader and Cluster is just a
|
|
10
|
+
# leader-failover wrapper.
|
|
11
|
+
#
|
|
12
|
+
# A Cluster is safe for sequential use from one thread. Like Connection, an
|
|
13
|
+
# open Rows pins its connection until closed.
|
|
14
|
+
|
|
15
|
+
require_relative "client"
|
|
16
|
+
require_relative "protocol"
|
|
17
|
+
|
|
18
|
+
module NextSQL
|
|
19
|
+
ClusterConn = Struct.new(:addr, :conn, :status, :seen, keyword_init: true) do
|
|
20
|
+
def initialize(**kwargs)
|
|
21
|
+
super({ status: nil, seen: 0.0 }.merge(kwargs))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Cluster
|
|
26
|
+
STATUS_TTL = 0.5 # seconds
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def connect(cfg)
|
|
30
|
+
addrs = cfg.nodes && !cfg.nodes.empty? ? cfg.nodes : (cfg.address && !cfg.address.empty? ? [cfg.address] : [])
|
|
31
|
+
raise Error.new("invalid_argument", "at least one node address is required") if addrs.empty?
|
|
32
|
+
|
|
33
|
+
cl = new
|
|
34
|
+
cl.instance_variable_set(:@read_consistency, cfg.read_consistency)
|
|
35
|
+
conns = []
|
|
36
|
+
first_err = nil
|
|
37
|
+
addrs.each do |addr|
|
|
38
|
+
nc = cfg.dup
|
|
39
|
+
nc.address = addr
|
|
40
|
+
nc.nodes = []
|
|
41
|
+
begin
|
|
42
|
+
conns << ClusterConn.new(addr: addr, conn: Connection.connect(nc))
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
first_err ||= e
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
raise(first_err || Error.new("unavailable", "no reachable node")) if conns.empty?
|
|
48
|
+
|
|
49
|
+
cl.instance_variable_set(:@conns, conns)
|
|
50
|
+
cl
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private :new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize
|
|
57
|
+
@conns = []
|
|
58
|
+
@rr = 0
|
|
59
|
+
@in_txn = false
|
|
60
|
+
@read_consistency = Protocol::READ_STRONG
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@conns.each { |cc| cc.conn.close }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def nodes
|
|
68
|
+
refresh
|
|
69
|
+
@conns.filter_map(&:status)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def exec(sql, params = [])
|
|
73
|
+
query(sql, params).collect
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def query(sql, params = [])
|
|
77
|
+
begin_, end_ = Connection.txn_control(sql)
|
|
78
|
+
routable = !@in_txn && !begin_ && !end_ &&
|
|
79
|
+
@read_consistency != Protocol::READ_STRONG &&
|
|
80
|
+
Connection.read_only_sql?(sql)
|
|
81
|
+
|
|
82
|
+
if routable
|
|
83
|
+
fc = follower_cluster_conn
|
|
84
|
+
if fc
|
|
85
|
+
begin
|
|
86
|
+
return fc.conn.query(sql, params)
|
|
87
|
+
rescue Error => e
|
|
88
|
+
if transport_failure?(e)
|
|
89
|
+
fc.status = nil
|
|
90
|
+
fc.seen = 0.0
|
|
91
|
+
elsif e.error_code != "unavailable"
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
# The follower lost the leader, fell outside the bound, or its
|
|
95
|
+
# connection just broke; the leader can always answer, so fall
|
|
96
|
+
# through.
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
leader_cc = leader_cluster_conn
|
|
102
|
+
rows = begin
|
|
103
|
+
leader_cc.conn.query(sql, params)
|
|
104
|
+
rescue Error => e
|
|
105
|
+
if transport_failure?(e)
|
|
106
|
+
# The connection we cached as "the leader" just broke — most
|
|
107
|
+
# commonly because that node lost leadership and was then
|
|
108
|
+
# drained or restarted for planned maintenance before the
|
|
109
|
+
# status cache caught up. Stop trusting that cached role (the
|
|
110
|
+
# next refresh re-probes) and surface "unavailable" instead of
|
|
111
|
+
# the raw transport error, so a caller already retrying on it
|
|
112
|
+
# (the standard way to survive a genuine leader failover)
|
|
113
|
+
# transparently survives this case too.
|
|
114
|
+
leader_cc.status = nil
|
|
115
|
+
leader_cc.seen = 0.0
|
|
116
|
+
raise Error.new("unavailable", "leader connection failed: #{e.message}")
|
|
117
|
+
end
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
@in_txn = begin_ if begin_ || end_
|
|
121
|
+
rows
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def transport_failure?(err)
|
|
127
|
+
# A broken connection (dial/read/write failure), not an application-
|
|
128
|
+
# level rejection the server sent back deliberately — see
|
|
129
|
+
# drivers/go/cluster.go isTransportFailure for the full reasoning
|
|
130
|
+
# this mirrors. Server-sent errors always decode with the server's
|
|
131
|
+
# own error code, never "io", so this cannot misclassify a
|
|
132
|
+
# legitimate query rejection as a dead connection.
|
|
133
|
+
err.error_code == "io"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def refresh
|
|
137
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
@conns.each do |cc|
|
|
139
|
+
next if now - cc.seen < STATUS_TTL
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
cc.status = cc.conn.node_status
|
|
143
|
+
cc.seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
|
+
rescue Error => e
|
|
145
|
+
if transport_failure?(e)
|
|
146
|
+
# The underlying Connection does not reconnect on its own, so
|
|
147
|
+
# a transport failure here is permanent for the lifetime of
|
|
148
|
+
# this Cluster: stop trusting whatever role it last reported
|
|
149
|
+
# (most dangerously "leader") rather than leaving stale data
|
|
150
|
+
# in place. It stays a refresh target so a future probe is
|
|
151
|
+
# still attempted, at the normal TTL cadence.
|
|
152
|
+
cc.status = nil
|
|
153
|
+
cc.seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
154
|
+
end
|
|
155
|
+
# else: keep the last known status.
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def leader_cluster_conn
|
|
161
|
+
refresh
|
|
162
|
+
@conns.each do |cc|
|
|
163
|
+
role = cc.status&.role
|
|
164
|
+
return cc if %w[leader standalone].include?(role)
|
|
165
|
+
end
|
|
166
|
+
raise Error.new("unavailable", "no reachable leader")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def follower_cluster_conn
|
|
170
|
+
refresh
|
|
171
|
+
followers = []
|
|
172
|
+
others = []
|
|
173
|
+
@conns.each do |cc|
|
|
174
|
+
next unless cc.status&.healthy
|
|
175
|
+
|
|
176
|
+
if cc.status.role == "follower"
|
|
177
|
+
followers << cc
|
|
178
|
+
elsif %w[leader standalone].include?(cc.status.role)
|
|
179
|
+
others << cc
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
pick = followers.empty? ? others : followers
|
|
183
|
+
return nil if pick.empty?
|
|
184
|
+
|
|
185
|
+
cc = pick[@rr % pick.size]
|
|
186
|
+
@rr += 1
|
|
187
|
+
cc
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextSQL
|
|
4
|
+
# An error a stable +error_code+ string plus a human message.
|
|
5
|
+
#
|
|
6
|
+
# +error_code+ matches the +nerr.Code+ string the server (or the client
|
|
7
|
+
# itself, for local protocol/argument errors) sends, e.g. "unavailable",
|
|
8
|
+
# "invalid_argument", "forbidden", "not_found". It is stable across
|
|
9
|
+
# releases and is the right thing to branch on, not the message text.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
attr_reader :error_code
|
|
12
|
+
|
|
13
|
+
def initialize(error_code, message = "")
|
|
14
|
+
@error_code = error_code
|
|
15
|
+
super(message.empty? ? error_code : message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|