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.
@@ -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