skaidb 1.0.3
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/CHANGELOG.md +122 -0
- data/LICENSE +557 -0
- data/README.md +484 -0
- data/docs/api.md +146 -0
- data/docs/getting-started.md +127 -0
- data/docs/pooling.md +47 -0
- data/docs/streaming.md +69 -0
- data/docs/tls.md +59 -0
- data/docs/types.md +72 -0
- data/lib/skaidb.rb +1334 -0
- metadata +78 -0
data/lib/skaidb.rb
ADDED
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# skaidb — Ruby driver.
|
|
4
|
+
#
|
|
5
|
+
# A small, dependency-free client for skaidb's binary wire protocol. The API is
|
|
6
|
+
# modelled on the {https://github.com/ged/ruby-pg ruby-pg} gem (`PG`), so if you
|
|
7
|
+
# have used Postgres from Ruby it should feel familiar: +Skaidb.connect+ returns
|
|
8
|
+
# a connection, +exec+ / +exec_params+ run statements, and the result behaves
|
|
9
|
+
# like +PG::Result+ (Enumerable of row Hashes, plus +rows+, +fields+,
|
|
10
|
+
# +ntuples+, +cmd_tuples+).
|
|
11
|
+
#
|
|
12
|
+
# Pure standard library — only +socket+, +openssl+, +securerandom+ and
|
|
13
|
+
# +bigdecimal+. No gems.
|
|
14
|
+
#
|
|
15
|
+
# require "skaidb"
|
|
16
|
+
#
|
|
17
|
+
# conn = Skaidb.connect(host: "localhost", port: 7000,
|
|
18
|
+
# user: "skaidb", password: "secret")
|
|
19
|
+
# conn.exec("CREATE TABLE users (PRIMARY KEY (id))")
|
|
20
|
+
# conn.exec_params("INSERT INTO users (id, name) VALUES ($1, $2)", [1, "Ada"])
|
|
21
|
+
# res = conn.exec_params("SELECT id, name FROM users WHERE id = $1", [1])
|
|
22
|
+
# res.each { |row| puts row["name"] } # => "Ada"
|
|
23
|
+
# conn.close
|
|
24
|
+
#
|
|
25
|
+
# Placeholders use the pg-style +$1+, +$2+, ... . They are sent as TYPED values
|
|
26
|
+
# through a server-side prepared statement where the server accepts one, and
|
|
27
|
+
# interpolated into the SQL client-side, with correct quoting, where it does not
|
|
28
|
+
# (old servers, and statement kinds that cannot be prepared).
|
|
29
|
+
require "socket"
|
|
30
|
+
require "openssl"
|
|
31
|
+
require "securerandom"
|
|
32
|
+
require "bigdecimal"
|
|
33
|
+
|
|
34
|
+
module Skaidb
|
|
35
|
+
# The package version — the single source of truth. The gemspec reads it,
|
|
36
|
+
# and it is what the driver reports to the server in the Hello frame.
|
|
37
|
+
VERSION = "1.0.3"
|
|
38
|
+
|
|
39
|
+
# Base class for every error raised by this driver.
|
|
40
|
+
class Error < StandardError; end
|
|
41
|
+
|
|
42
|
+
# A connection / transport problem (socket, framing, handshake). When this is
|
|
43
|
+
# raised mid-stream the connection is dead and must be discarded.
|
|
44
|
+
class ConnectionError < Error; end
|
|
45
|
+
|
|
46
|
+
# A statement failed (bad SQL, constraint violation, ...). The connection
|
|
47
|
+
# stays usable for the next query.
|
|
48
|
+
class QueryError < Error; end
|
|
49
|
+
|
|
50
|
+
# Consistency levels — how many replicas must acknowledge/answer.
|
|
51
|
+
module Consistency
|
|
52
|
+
ONE = 0
|
|
53
|
+
QUORUM = 1
|
|
54
|
+
ALL = 2
|
|
55
|
+
|
|
56
|
+
BY_NAME = { "one" => 0, "quorum" => 1, "all" => 2 }.freeze
|
|
57
|
+
|
|
58
|
+
# Resolve a symbol/string/integer into the 0/1/2 wire value.
|
|
59
|
+
def self.resolve(value)
|
|
60
|
+
case value
|
|
61
|
+
when Integer
|
|
62
|
+
return value if [0, 1, 2].include?(value)
|
|
63
|
+
|
|
64
|
+
raise Error, "invalid consistency #{value.inspect}"
|
|
65
|
+
when String, Symbol
|
|
66
|
+
v = BY_NAME[value.to_s.downcase]
|
|
67
|
+
return v if v
|
|
68
|
+
|
|
69
|
+
raise Error, "invalid consistency #{value.inspect}"
|
|
70
|
+
else
|
|
71
|
+
raise Error, "invalid consistency #{value.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Value type tags (§4 of PROTOCOL.md).
|
|
77
|
+
module Tags
|
|
78
|
+
NULL = 0
|
|
79
|
+
BOOL = 1
|
|
80
|
+
INT = 2
|
|
81
|
+
FLOAT = 3
|
|
82
|
+
DECIMAL = 4
|
|
83
|
+
STRING = 5
|
|
84
|
+
BYTES = 6
|
|
85
|
+
UUID = 7
|
|
86
|
+
TIMESTAMP = 8
|
|
87
|
+
ARRAY = 9
|
|
88
|
+
DOCUMENT = 10
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A UUID parameter. Results decode Uuid cells to their canonical lowercase
|
|
92
|
+
# String, and a String binds as a String, so wrap a value in +Uuid+ to bind
|
|
93
|
+
# it with the Uuid type tag: +Skaidb::Uuid.new("6ba7b810-9dad-11d1-80b4-00c04fd430c8")+.
|
|
94
|
+
# Compares equal to another Uuid, or to a String, with the same canonical form.
|
|
95
|
+
class Uuid
|
|
96
|
+
HEX32 = /\A\h{32}\z/.freeze
|
|
97
|
+
|
|
98
|
+
# @return [String] canonical lowercase 8-4-4-4-12 form
|
|
99
|
+
attr_reader :to_s
|
|
100
|
+
|
|
101
|
+
# @param str [String] 32 hex digits, with or without the usual dashes
|
|
102
|
+
def initialize(str)
|
|
103
|
+
hex = str.to_s.delete("-").downcase
|
|
104
|
+
raise ArgumentError, "not a UUID: #{str.inspect}" unless HEX32.match?(hex)
|
|
105
|
+
|
|
106
|
+
@to_s = Skaidb.format_uuid([hex].pack("H*")).freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build from the 16 raw bytes.
|
|
110
|
+
def self.from_bytes(bytes)
|
|
111
|
+
raise ArgumentError, "a UUID is 16 bytes" unless bytes.bytesize == 16
|
|
112
|
+
|
|
113
|
+
new(bytes.unpack1("H*"))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A random (version 4) UUID.
|
|
117
|
+
def self.random
|
|
118
|
+
new(SecureRandom.uuid)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [String] the 16 raw bytes (RFC 4122 order)
|
|
122
|
+
def bytes
|
|
123
|
+
[@to_s.delete("-")].pack("H*")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ==(other)
|
|
127
|
+
case other
|
|
128
|
+
when Uuid then @to_s == other.to_s
|
|
129
|
+
when String
|
|
130
|
+
hex = other.delete("-").downcase
|
|
131
|
+
HEX32.match?(hex) && @to_s == Skaidb.format_uuid([hex].pack("H*"))
|
|
132
|
+
else false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
alias eql? ==
|
|
136
|
+
|
|
137
|
+
def hash
|
|
138
|
+
@to_s.hash
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def inspect
|
|
142
|
+
"#<Skaidb::Uuid #{@to_s}>"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ---- byte reader ---------------------------------------------------------
|
|
147
|
+
#
|
|
148
|
+
# All integers here are little-endian (the frame length prefix, handled in
|
|
149
|
+
# Connection, is the sole big-endian field).
|
|
150
|
+
class Reader
|
|
151
|
+
def initialize(buf)
|
|
152
|
+
@buf = buf
|
|
153
|
+
@pos = 0
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def take(n)
|
|
157
|
+
raise ConnectionError, "truncated server message" if @pos + n > @buf.bytesize
|
|
158
|
+
|
|
159
|
+
s = @buf.byteslice(@pos, n)
|
|
160
|
+
@pos += n
|
|
161
|
+
s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def u8
|
|
165
|
+
take(1).unpack1("C")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def u16
|
|
169
|
+
take(2).unpack1("v")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def u32
|
|
173
|
+
take(4).unpack1("V")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def i64
|
|
177
|
+
take(8).unpack1("q<")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def u64
|
|
181
|
+
take(8).unpack1("Q<")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def f64
|
|
185
|
+
take(8).unpack1("E")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# signed little-endian 128-bit integer (16 bytes), assembled manually.
|
|
189
|
+
def i128
|
|
190
|
+
bytes = take(16)
|
|
191
|
+
lo = bytes.byteslice(0, 8).unpack1("Q<")
|
|
192
|
+
hi = bytes.byteslice(8, 8).unpack1("q<") # high half is signed
|
|
193
|
+
(hi << 64) | lo
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def blob
|
|
197
|
+
take(u32)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def text
|
|
201
|
+
blob.force_encoding("UTF-8")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Decode one self-describing Value (§4) from the reader.
|
|
206
|
+
def self.decode_value(r)
|
|
207
|
+
tag = r.u8
|
|
208
|
+
case tag
|
|
209
|
+
when Tags::NULL then nil
|
|
210
|
+
when Tags::BOOL then r.u8 != 0
|
|
211
|
+
when Tags::INT then r.i64
|
|
212
|
+
when Tags::FLOAT then r.f64
|
|
213
|
+
when Tags::DECIMAL
|
|
214
|
+
mantissa = r.i128
|
|
215
|
+
scale = r.u32
|
|
216
|
+
# value = mantissa / 10^scale, built exactly via BigDecimal("<mantissa>e-<scale>")
|
|
217
|
+
BigDecimal("#{mantissa}e-#{scale}")
|
|
218
|
+
when Tags::STRING then r.text
|
|
219
|
+
when Tags::BYTES then r.blob.force_encoding("BINARY")
|
|
220
|
+
when Tags::UUID then format_uuid(r.take(16))
|
|
221
|
+
when Tags::TIMESTAMP
|
|
222
|
+
ms = r.i64
|
|
223
|
+
# preserve millisecond precision; Time in UTC
|
|
224
|
+
Time.at(ms / 1000, (ms % 1000) * 1000, :usec).utc
|
|
225
|
+
when Tags::ARRAY
|
|
226
|
+
Array.new(r.u32) { decode_value(r) }
|
|
227
|
+
when Tags::DOCUMENT
|
|
228
|
+
out = {}
|
|
229
|
+
r.u32.times do
|
|
230
|
+
key = r.text
|
|
231
|
+
out[key] = decode_value(r)
|
|
232
|
+
end
|
|
233
|
+
out
|
|
234
|
+
else
|
|
235
|
+
raise ConnectionError, "unknown value tag #{tag}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# 16 raw bytes -> canonical lowercase 8-4-4-4-12 UUID string.
|
|
240
|
+
def self.format_uuid(bytes)
|
|
241
|
+
hex = bytes.unpack1("H*")
|
|
242
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ---- client-side parameter binding (§5) ----------------------------------
|
|
246
|
+
|
|
247
|
+
# Quote a single Ruby value into a SQL literal.
|
|
248
|
+
def self.quote(arg)
|
|
249
|
+
case arg
|
|
250
|
+
when nil
|
|
251
|
+
"NULL"
|
|
252
|
+
when true
|
|
253
|
+
"TRUE"
|
|
254
|
+
when false
|
|
255
|
+
"FALSE"
|
|
256
|
+
when Integer
|
|
257
|
+
arg.to_s
|
|
258
|
+
when Float
|
|
259
|
+
raise QueryError, "cannot bind NaN/Infinity" if arg.nan? || arg.infinite?
|
|
260
|
+
|
|
261
|
+
# round-trip-safe representation
|
|
262
|
+
arg.to_s
|
|
263
|
+
when BigDecimal
|
|
264
|
+
arg.to_s("F")
|
|
265
|
+
when String
|
|
266
|
+
if arg.encoding == Encoding::BINARY
|
|
267
|
+
# raw binary bytes (ASCII-8BIT) -> hex literal, mirroring Python's bytes
|
|
268
|
+
"'" + arg.unpack1("H*") + "'"
|
|
269
|
+
else
|
|
270
|
+
# text string -> single-quoted, doubling embedded quotes
|
|
271
|
+
"'" + arg.gsub("'", "''") + "'"
|
|
272
|
+
end
|
|
273
|
+
when Symbol
|
|
274
|
+
"'" + arg.to_s.gsub("'", "''") + "'"
|
|
275
|
+
when Uuid
|
|
276
|
+
"'" + arg.to_s + "'"
|
|
277
|
+
when Time
|
|
278
|
+
time_ms(arg).to_s
|
|
279
|
+
else
|
|
280
|
+
raise QueryError, "cannot bind value of type #{arg.class}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Interpolate +params+ into +sql+, replacing $1, $2, ... placeholders that
|
|
285
|
+
# appear outside single-quoted string literals.
|
|
286
|
+
# Rewrite pg-style +$N+ placeholders to the positional +?+ the server's
|
|
287
|
+
# prepared statements use, returning [sql, params_in_wire_order]. A
|
|
288
|
+
# parameter referenced twice is sent twice — +?+ is positional and cannot
|
|
289
|
+
# say "the same one again". +$N+ inside a string literal is left alone.
|
|
290
|
+
# Like +bind+, a parameter no placeholder references is an error: the
|
|
291
|
+
# server only ever sees the referenced values, so without this check an
|
|
292
|
+
# extra value (a batch row one column too long, say) would vanish silently.
|
|
293
|
+
def self.to_qmark(sql, params)
|
|
294
|
+
params ||= []
|
|
295
|
+
out = +""
|
|
296
|
+
order = []
|
|
297
|
+
max_used = 0
|
|
298
|
+
in_str = false
|
|
299
|
+
i = 0
|
|
300
|
+
n = sql.length
|
|
301
|
+
while i < n
|
|
302
|
+
ch = sql[i]
|
|
303
|
+
if in_str
|
|
304
|
+
out << ch
|
|
305
|
+
if ch == "'"
|
|
306
|
+
if i + 1 < n && sql[i + 1] == "'"
|
|
307
|
+
out << "'"
|
|
308
|
+
i += 2
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
in_str = false
|
|
312
|
+
end
|
|
313
|
+
i += 1
|
|
314
|
+
next
|
|
315
|
+
end
|
|
316
|
+
if ch == "'"
|
|
317
|
+
in_str = true
|
|
318
|
+
out << ch
|
|
319
|
+
i += 1
|
|
320
|
+
next
|
|
321
|
+
end
|
|
322
|
+
if ch == "$" && i + 1 < n && sql[i + 1] =~ /[0-9]/
|
|
323
|
+
j = i + 1
|
|
324
|
+
j += 1 while j < n && sql[j] =~ /[0-9]/
|
|
325
|
+
idx = sql[(i + 1)...j].to_i
|
|
326
|
+
raise QueryError, "invalid placeholder $0" if idx < 1
|
|
327
|
+
raise QueryError, "placeholder $#{idx} has no parameter" if idx > params.length
|
|
328
|
+
|
|
329
|
+
order << params[idx - 1]
|
|
330
|
+
max_used = idx if idx > max_used
|
|
331
|
+
out << "?"
|
|
332
|
+
i = j
|
|
333
|
+
next
|
|
334
|
+
end
|
|
335
|
+
if ch == "?" && !params.empty?
|
|
336
|
+
# A `?` here would reach the server as ITS placeholder and fail late
|
|
337
|
+
# with a confusing arity error; say what the driver's syntax is.
|
|
338
|
+
raise QueryError, "this driver uses $1, $2, ... placeholders; '?' is not a placeholder"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
out << ch
|
|
342
|
+
i += 1
|
|
343
|
+
end
|
|
344
|
+
if params.length > max_used
|
|
345
|
+
raise QueryError, "more parameters (#{params.length}) than placeholders ($#{max_used})"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
[out, order]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
I64_MIN = -(2**63)
|
|
352
|
+
I64_MAX = 2**63 - 1
|
|
353
|
+
I128_MIN = -(2**127)
|
|
354
|
+
I128_MAX = 2**127 - 1
|
|
355
|
+
U128_MASK = 2**128 - 1
|
|
356
|
+
U64_MASK = 2**64 - 1
|
|
357
|
+
|
|
358
|
+
# A BigDecimal as [mantissa, scale] with value = mantissa / 10^scale,
|
|
359
|
+
# matching the wire codec. The scale is unsigned, so a positive exponent is
|
|
360
|
+
# folded into the mantissa.
|
|
361
|
+
def self.decimal_parts(d)
|
|
362
|
+
raise QueryError, "cannot bind non-finite BigDecimal" if d.nan? || d.infinite?
|
|
363
|
+
|
|
364
|
+
sign, digits, _base, exponent = d.split # value = sign * 0.<digits> * 10^exponent
|
|
365
|
+
digits = digits.sub(/0+\z/, "")
|
|
366
|
+
digits = "0" if digits.empty?
|
|
367
|
+
mantissa = digits.to_i
|
|
368
|
+
mantissa = -mantissa if sign.negative?
|
|
369
|
+
scale = digits.length - exponent
|
|
370
|
+
if scale.negative?
|
|
371
|
+
mantissa *= 10**-scale
|
|
372
|
+
scale = 0
|
|
373
|
+
end
|
|
374
|
+
if mantissa < I128_MIN || mantissa > I128_MAX
|
|
375
|
+
raise QueryError, "BigDecimal mantissa does not fit a signed 128-bit integer"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
[mantissa, scale]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# A Ruby Time as Unix milliseconds — exact (via Rational), truncated
|
|
382
|
+
# towards negative infinity so that decoding gives the same millisecond.
|
|
383
|
+
def self.time_ms(t)
|
|
384
|
+
(t.to_r * 1000).floor
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Encode a Ruby value as a TYPED skaidb value (tag + payload), the inverse
|
|
388
|
+
# of decode_value. Arrays become Array and Hashes become Document — the
|
|
389
|
+
# point of the prepared path, since neither has a SQL literal form.
|
|
390
|
+
def self.encode_value(v)
|
|
391
|
+
case v
|
|
392
|
+
when nil then [0].pack("C")
|
|
393
|
+
when true then [1, 1].pack("CC")
|
|
394
|
+
when false then [1, 0].pack("CC")
|
|
395
|
+
when Integer
|
|
396
|
+
raise QueryError, "integer #{v} does not fit a signed 64-bit Int" if v < I64_MIN || v > I64_MAX
|
|
397
|
+
|
|
398
|
+
[2].pack("C") + [v].pack("q<")
|
|
399
|
+
when Float
|
|
400
|
+
raise QueryError, "cannot bind NaN/Infinity" if v.nan? || v.infinite?
|
|
401
|
+
|
|
402
|
+
[3].pack("C") + [v].pack("E")
|
|
403
|
+
when BigDecimal
|
|
404
|
+
mantissa, scale = decimal_parts(v)
|
|
405
|
+
m = mantissa & U128_MASK
|
|
406
|
+
[4].pack("C") + [m & U64_MASK, m >> 64].pack("Q<Q<") + [scale].pack("V")
|
|
407
|
+
when String
|
|
408
|
+
if v.encoding == Encoding::BINARY
|
|
409
|
+
# ASCII-8BIT is how Ruby says "raw bytes": bind as Bytes.
|
|
410
|
+
[6].pack("C") + [v.bytesize].pack("V") + v
|
|
411
|
+
else
|
|
412
|
+
b = v.dup.force_encoding(Encoding::BINARY)
|
|
413
|
+
[5].pack("C") + [b.bytesize].pack("V") + b
|
|
414
|
+
end
|
|
415
|
+
when Symbol
|
|
416
|
+
encode_value(v.to_s)
|
|
417
|
+
when Uuid
|
|
418
|
+
[7].pack("C") + v.bytes
|
|
419
|
+
when Time
|
|
420
|
+
[8].pack("C") + [time_ms(v)].pack("q<")
|
|
421
|
+
when Array
|
|
422
|
+
out = +([9].pack("C") + [v.length].pack("V"))
|
|
423
|
+
v.each { |item| out << encode_value(item) }
|
|
424
|
+
out
|
|
425
|
+
when Hash
|
|
426
|
+
out = +([10].pack("C") + [v.length].pack("V"))
|
|
427
|
+
v.each do |k, val|
|
|
428
|
+
ks = k.to_s.dup.force_encoding(Encoding::BINARY)
|
|
429
|
+
out << [ks.bytesize].pack("V") << ks << encode_value(val)
|
|
430
|
+
end
|
|
431
|
+
out
|
|
432
|
+
else
|
|
433
|
+
raise QueryError, "cannot bind value of type #{v.class}"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def self.bind(sql, params)
|
|
438
|
+
params ||= []
|
|
439
|
+
out = +""
|
|
440
|
+
in_str = false
|
|
441
|
+
i = 0
|
|
442
|
+
n = sql.length
|
|
443
|
+
max_used = 0
|
|
444
|
+
while i < n
|
|
445
|
+
ch = sql[i]
|
|
446
|
+
if in_str
|
|
447
|
+
out << ch
|
|
448
|
+
if ch == "'"
|
|
449
|
+
if i + 1 < n && sql[i + 1] == "'"
|
|
450
|
+
out << "'"
|
|
451
|
+
i += 2
|
|
452
|
+
next
|
|
453
|
+
end
|
|
454
|
+
in_str = false
|
|
455
|
+
end
|
|
456
|
+
i += 1
|
|
457
|
+
next
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if ch == "'"
|
|
461
|
+
in_str = true
|
|
462
|
+
out << ch
|
|
463
|
+
i += 1
|
|
464
|
+
next
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
if ch == "$" && i + 1 < n && sql[i + 1] =~ /[0-9]/
|
|
468
|
+
j = i + 1
|
|
469
|
+
j += 1 while j < n && sql[j] =~ /[0-9]/
|
|
470
|
+
idx = sql[(i + 1)...j].to_i
|
|
471
|
+
raise QueryError, "invalid placeholder $0" if idx < 1
|
|
472
|
+
raise QueryError, "placeholder $#{idx} has no parameter" if idx > params.length
|
|
473
|
+
|
|
474
|
+
out << quote(params[idx - 1])
|
|
475
|
+
max_used = idx if idx > max_used
|
|
476
|
+
i = j
|
|
477
|
+
next
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
out << ch
|
|
481
|
+
i += 1
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
if params.length > max_used
|
|
485
|
+
raise QueryError, "more parameters (#{params.length}) than placeholders ($#{max_used})"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
out
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# ---- SCRAM-SHA-256 (§2) --------------------------------------------------
|
|
492
|
+
|
|
493
|
+
# Compute the client proof and the expected server signature.
|
|
494
|
+
def self.scram(password, salt, iterations, auth_message)
|
|
495
|
+
digest = OpenSSL::Digest::SHA256
|
|
496
|
+
salted = OpenSSL::KDF.pbkdf2_hmac(
|
|
497
|
+
password, salt: salt, iterations: iterations, length: 32, hash: "sha256"
|
|
498
|
+
)
|
|
499
|
+
client_key = OpenSSL::HMAC.digest(digest.new, salted, "Client Key")
|
|
500
|
+
stored_key = OpenSSL::Digest::SHA256.digest(client_key)
|
|
501
|
+
client_sig = OpenSSL::HMAC.digest(digest.new, stored_key, auth_message)
|
|
502
|
+
proof = xor_bytes(client_key, client_sig)
|
|
503
|
+
server_key = OpenSSL::HMAC.digest(digest.new, salted, "Server Key")
|
|
504
|
+
server_sig = OpenSSL::HMAC.digest(digest.new, server_key, auth_message)
|
|
505
|
+
[proof, server_sig]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def self.xor_bytes(a, b)
|
|
509
|
+
a.bytes.zip(b.bytes).map { |x, y| x ^ y }.pack("C*")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Constant-time comparison for the server signature.
|
|
513
|
+
def self.secure_compare(a, b)
|
|
514
|
+
return false unless a.bytesize == b.bytesize
|
|
515
|
+
|
|
516
|
+
res = 0
|
|
517
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
|
518
|
+
res.zero?
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# encode a string field: u32 LE length + UTF-8 bytes
|
|
522
|
+
def self.enc_str(str)
|
|
523
|
+
b = str.to_s.dup.force_encoding("UTF-8")
|
|
524
|
+
bytes = b.bytesize
|
|
525
|
+
[bytes].pack("V") + b.b
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# ---- Result --------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
# A query result, shaped like +PG::Result+. Enumerable over row Hashes
|
|
531
|
+
# (String column-name keys); also exposes positional +rows+, +fields+,
|
|
532
|
+
# +ntuples+ and +cmd_tuples+.
|
|
533
|
+
class Result
|
|
534
|
+
include Enumerable
|
|
535
|
+
|
|
536
|
+
# @return [Array<String>] column names, in order
|
|
537
|
+
attr_reader :fields
|
|
538
|
+
# @return [Array<Array>] rows as arrays of values
|
|
539
|
+
attr_reader :rows
|
|
540
|
+
# @return [Integer] number of rows affected by a mutation (0 otherwise)
|
|
541
|
+
attr_reader :cmd_tuples
|
|
542
|
+
# @return [Array<Result>] every result set of a multi-set reply (a CALL
|
|
543
|
+
# whose body EMITted), in order; this Result is the LAST of them.
|
|
544
|
+
# Empty for an ordinary single-set reply.
|
|
545
|
+
attr_reader :result_sets
|
|
546
|
+
|
|
547
|
+
def initialize(fields:, rows:, cmd_tuples: 0, result_sets: [])
|
|
548
|
+
@result_sets = result_sets
|
|
549
|
+
@fields = fields
|
|
550
|
+
@rows = rows
|
|
551
|
+
@cmd_tuples = cmd_tuples
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# pg alias for the column names.
|
|
555
|
+
alias columns fields
|
|
556
|
+
|
|
557
|
+
# @return [Integer] number of result rows
|
|
558
|
+
def ntuples
|
|
559
|
+
@rows.length
|
|
560
|
+
end
|
|
561
|
+
alias num_tuples ntuples
|
|
562
|
+
|
|
563
|
+
# @return [Integer] number of columns
|
|
564
|
+
def nfields
|
|
565
|
+
@fields.length
|
|
566
|
+
end
|
|
567
|
+
alias num_fields nfields
|
|
568
|
+
|
|
569
|
+
# Yield each row as a Hash keyed by column name (String keys).
|
|
570
|
+
def each
|
|
571
|
+
return enum_for(:each) unless block_given?
|
|
572
|
+
|
|
573
|
+
@rows.each do |row|
|
|
574
|
+
h = {}
|
|
575
|
+
@fields.each_with_index { |name, idx| h[name] = row[idx] }
|
|
576
|
+
yield h
|
|
577
|
+
end
|
|
578
|
+
self
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Row +i+ as a Hash keyed by column name.
|
|
582
|
+
def [](i)
|
|
583
|
+
row = @rows[i]
|
|
584
|
+
return nil if row.nil?
|
|
585
|
+
|
|
586
|
+
h = {}
|
|
587
|
+
@fields.each_with_index { |name, idx| h[name] = row[idx] }
|
|
588
|
+
h
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# A single field value by row index and column (name or index).
|
|
592
|
+
def getvalue(row, col)
|
|
593
|
+
r = @rows[row]
|
|
594
|
+
return nil if r.nil?
|
|
595
|
+
|
|
596
|
+
col = @fields.index(col) if col.is_a?(String)
|
|
597
|
+
col.nil? ? nil : r[col]
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# All rows as Hashes.
|
|
601
|
+
def values
|
|
602
|
+
to_a
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# ---- Connection ----------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
@nonce_counter = 0
|
|
609
|
+
@nonce_mutex = Mutex.new
|
|
610
|
+
|
|
611
|
+
# Internal: a process-wide monotonic counter for client nonces.
|
|
612
|
+
def self.next_nonce_id
|
|
613
|
+
@nonce_mutex.synchronize { @nonce_counter += 1 }
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# A connection to one skaidb node. Modelled on +PG::Connection+.
|
|
617
|
+
class Connection
|
|
618
|
+
# @return [Boolean] whether the connection has been closed
|
|
619
|
+
attr_reader :closed
|
|
620
|
+
|
|
621
|
+
def initialize(host:, port:, user:, password:, consistency:, timeout:,
|
|
622
|
+
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
623
|
+
tls_server_name: "skaidb", seeds: nil)
|
|
624
|
+
@consistency = Consistency.resolve(consistency)
|
|
625
|
+
@mutex = Mutex.new
|
|
626
|
+
@closed = false
|
|
627
|
+
# Transport died; the next statement re-dials (see ensure_live!).
|
|
628
|
+
@broken = false
|
|
629
|
+
# A stream is in flight, so the socket sits mid-reply (see stream).
|
|
630
|
+
@streaming = false
|
|
631
|
+
@prepared = {}
|
|
632
|
+
@last_prepare_error = nil
|
|
633
|
+
# Retained so a reconnect repeats the original connect exactly.
|
|
634
|
+
@dial_args = { host: host, port: port, user: user, password: password,
|
|
635
|
+
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
636
|
+
tls_insecure: tls_insecure, tls_server_name: tls_server_name,
|
|
637
|
+
seeds: seeds }
|
|
638
|
+
dial!
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Connect, authenticate and enter the session database. Used for the first
|
|
642
|
+
# connect and for every reconnect, so a recovered connection is
|
|
643
|
+
# indistinguishable from a fresh one.
|
|
644
|
+
def dial!
|
|
645
|
+
host = @dial_args[:host]
|
|
646
|
+
port = @dial_args[:port]
|
|
647
|
+
user = @dial_args[:user]
|
|
648
|
+
password = @dial_args[:password]
|
|
649
|
+
timeout = @dial_args[:timeout]
|
|
650
|
+
database = @dial_args[:database]
|
|
651
|
+
tls = @dial_args[:tls]
|
|
652
|
+
tls_ca = @dial_args[:tls_ca]
|
|
653
|
+
tls_insecure = @dial_args[:tls_insecure]
|
|
654
|
+
tls_server_name = @dial_args[:tls_server_name]
|
|
655
|
+
seeds = @dial_args[:seeds]
|
|
656
|
+
# Seeds: try each until one connects AND authenticates — a node that
|
|
657
|
+
# accepts TCP while unhealthy must not swallow the attempt. skaidb is
|
|
658
|
+
# 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
|
|
664
|
+
last = nil
|
|
665
|
+
endpoints.each do |(h, prt)|
|
|
666
|
+
begin
|
|
667
|
+
@sock = Socket.tcp(h, prt, connect_timeout: timeout)
|
|
668
|
+
@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)
|
|
671
|
+
last = nil
|
|
672
|
+
break
|
|
673
|
+
rescue StandardError => e
|
|
674
|
+
last = e
|
|
675
|
+
begin
|
|
676
|
+
@sock&.close
|
|
677
|
+
rescue StandardError
|
|
678
|
+
nil
|
|
679
|
+
end
|
|
680
|
+
@sock = nil
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
if last
|
|
684
|
+
@closed = true
|
|
685
|
+
raise ConnectionError,
|
|
686
|
+
"no reachable endpoint in #{endpoints.map { |(h, q)| "#{h}:#{q}" }.join(', ')}: #{last.message}"
|
|
687
|
+
end
|
|
688
|
+
send_hello
|
|
689
|
+
# USE is per-connection session state, so it runs on every dial.
|
|
690
|
+
exec(%(USE "#{database.to_s.gsub('"', '""')}")) if database && !database.to_s.empty?
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Best-effort self-identification: fills the server's +drivers+ table
|
|
694
|
+
# client_name/client_version. An old server answers the unknown opcode
|
|
695
|
+
# with an error frame, which is ignored — identity is telemetry, never
|
|
696
|
+
# load-bearing.
|
|
697
|
+
def send_hello
|
|
698
|
+
name = "ruby"
|
|
699
|
+
ver = Skaidb::VERSION # the package version, never a literal
|
|
700
|
+
req = [8].pack("C") + [name.bytesize].pack("V") + name +
|
|
701
|
+
[ver.bytesize].pack("V") + ver
|
|
702
|
+
write_frame(req)
|
|
703
|
+
read_frame
|
|
704
|
+
rescue StandardError
|
|
705
|
+
nil
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Upgrade a connected socket to TLS. A server with client_tls = required
|
|
709
|
+
# refuses plaintext outright, so without this such a cluster is simply
|
|
710
|
+
# unreachable. +tls_server_name+ must match a SAN on the server
|
|
711
|
+
# certificate — skaidb's own certs carry DNS:skaidb, which is usually NOT
|
|
712
|
+
# the address dialled.
|
|
713
|
+
def tls_wrap(sock, ca_file, insecure, server_name)
|
|
714
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
715
|
+
if insecure
|
|
716
|
+
# Encrypts, but authenticates nothing: a man in the middle can present
|
|
717
|
+
# any certificate. Development only.
|
|
718
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
719
|
+
else
|
|
720
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
721
|
+
ctx.cert_store = OpenSSL::X509::Store.new.tap do |store|
|
|
722
|
+
ca_file && !ca_file.empty? ? store.add_file(ca_file) : store.set_default_paths
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
|
|
726
|
+
ssl.hostname = server_name # SNI
|
|
727
|
+
ssl.sync_close = true
|
|
728
|
+
ssl.connect
|
|
729
|
+
ssl.post_connection_check(server_name) unless insecure
|
|
730
|
+
ssl
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Current default consistency level (0/1/2).
|
|
734
|
+
attr_reader :consistency
|
|
735
|
+
|
|
736
|
+
# Override the default consistency level for subsequent queries.
|
|
737
|
+
def consistency=(value)
|
|
738
|
+
@consistency = Consistency.resolve(value)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Execute a statement with no parameters.
|
|
742
|
+
# @return [Result]
|
|
743
|
+
def exec(sql)
|
|
744
|
+
run(sql.to_s, @consistency)
|
|
745
|
+
end
|
|
746
|
+
alias query exec
|
|
747
|
+
|
|
748
|
+
# Execute a statement, interpolating +params+ at +$1+, +$2+, ... .
|
|
749
|
+
# @param params [Array] positional parameters
|
|
750
|
+
# @return [Result]
|
|
751
|
+
def exec_params(sql, params = [], consistency: nil)
|
|
752
|
+
level = consistency.nil? ? @consistency : Consistency.resolve(consistency)
|
|
753
|
+
params ||= []
|
|
754
|
+
unless params.empty?
|
|
755
|
+
# Server-side prepare so parameters travel as TYPED values; arrays
|
|
756
|
+
# and Hashes have no SQL literal form. $N is rewritten to the
|
|
757
|
+
# positional ? the server expects.
|
|
758
|
+
qsql, order = Skaidb.to_qmark(sql.to_s, params)
|
|
759
|
+
prep = prepare_server(qsql)
|
|
760
|
+
unless prep.nil?
|
|
761
|
+
id, nparams = prep
|
|
762
|
+
if nparams != order.length
|
|
763
|
+
raise QueryError, "statement expects #{nparams} parameters, got #{order.length}"
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
return exec_prepared(id, order, level)
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
text = begin
|
|
770
|
+
Skaidb.bind(sql.to_s, params)
|
|
771
|
+
rescue QueryError => e
|
|
772
|
+
# The text path cannot carry this value (an Array, a Hash, ...), so
|
|
773
|
+
# the interesting error is the server's reason for refusing to
|
|
774
|
+
# prepare — usually a SQL mistake — not the fallback's limitation.
|
|
775
|
+
raise QueryError, "#{e.message}; the server would not prepare the statement: #{@last_prepare_error}" if @last_prepare_error
|
|
776
|
+
|
|
777
|
+
raise
|
|
778
|
+
end
|
|
779
|
+
run(text, level)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Execute +sql+ once per row in ONE round-trip. Rows autocommit
|
|
783
|
+
# individually: a failure names the row and earlier rows stay applied,
|
|
784
|
+
# so the statement must be idempotent. Returns total affected rows.
|
|
785
|
+
def exec_batch(sql, rows, consistency: nil)
|
|
786
|
+
return 0 if rows.nil? || rows.empty?
|
|
787
|
+
|
|
788
|
+
level = consistency.nil? ? @consistency : Consistency.resolve(consistency)
|
|
789
|
+
qsql, = Skaidb.to_qmark(sql.to_s, rows.first)
|
|
790
|
+
prep = prepare_server(qsql)
|
|
791
|
+
raise QueryError, "statement cannot be prepared, so it cannot be batched" if prep.nil?
|
|
792
|
+
|
|
793
|
+
id, nparams = prep
|
|
794
|
+
ordered = rows.map { |r| Skaidb.to_qmark(sql.to_s, r)[1] }
|
|
795
|
+
ordered.each do |r|
|
|
796
|
+
raise QueryError, "batch row expects #{nparams} parameters, got #{r.length}" if r.length != nparams
|
|
797
|
+
end
|
|
798
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
799
|
+
|
|
800
|
+
req = +([7, level].pack("CC") + [id].pack("V") + [ordered.length].pack("V"))
|
|
801
|
+
ordered.each do |r|
|
|
802
|
+
req << [r.length].pack("v")
|
|
803
|
+
r.each do |v|
|
|
804
|
+
b = Skaidb.encode_value(v)
|
|
805
|
+
req << [b.bytesize].pack("V") << b
|
|
806
|
+
end
|
|
807
|
+
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
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Stream a result set: yields one row Hash at a time while holding a
|
|
817
|
+
# single chunk, instead of buffering the whole result. For exports and
|
|
818
|
+
# large scans.
|
|
819
|
+
#
|
|
820
|
+
# conn.stream("SELECT ...") { |row| puts row["id"] }
|
|
821
|
+
#
|
|
822
|
+
# The protocol forbids any other request on the connection until the
|
|
823
|
+
# stream ends, so the whole exchange runs under the connection mutex
|
|
824
|
+
# instead of one lock per frame: a statement from another thread WAITS
|
|
825
|
+
# for the stream rather than interleaving frames with it.
|
|
826
|
+
#
|
|
827
|
+
# Leaving the block early — +break+, +return+ or an exception — unwinds
|
|
828
|
+
# through the ensure below, which drains whatever the server is still
|
|
829
|
+
# sending so the connection sits at a request boundary again. When it
|
|
830
|
+
# cannot (dead socket, a frame that makes no sense mid-stream) the
|
|
831
|
+
# connection is marked broken instead, so +usable?+ turns false and Pool
|
|
832
|
+
# drops it rather than handing out a desynced socket.
|
|
833
|
+
#
|
|
834
|
+
# Draining is not free and there is no cancel opcode: breaking out of a
|
|
835
|
+
# million-row scan still transfers the rest of it before the connection
|
|
836
|
+
# is usable again. If you only want the first few rows, say so in SQL
|
|
837
|
+
# (+LIMIT+) rather than by abandoning the stream.
|
|
838
|
+
#
|
|
839
|
+
# With no block this returns an Enumerator. +each+, +map+, +take+ and the
|
|
840
|
+
# rest of Enumerable are safe — they unwind through the ensure. External
|
|
841
|
+
# iteration (+next+, +peek+) is NOT: it runs the stream inside the
|
|
842
|
+
# Enumerator's Fiber, and a Fiber abandoned part-way is collected without
|
|
843
|
+
# running any ensure, so nothing drains and the mutex is never released.
|
|
844
|
+
# Such a connection stays marked busy on purpose (+usable?+ is false for
|
|
845
|
+
# the whole stream), which is what keeps it out of the pool. Iterate with
|
|
846
|
+
# a block or +each+ if you mean to reuse the connection.
|
|
847
|
+
#
|
|
848
|
+
# Takes no parameters — the streaming opcode carries SQL text.
|
|
849
|
+
def stream(sql, consistency: nil)
|
|
850
|
+
return enum_for(:stream, sql, consistency: consistency) unless block_given?
|
|
851
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
852
|
+
ensure_live!
|
|
853
|
+
|
|
854
|
+
level = consistency.nil? ? @consistency : Consistency.resolve(consistency)
|
|
855
|
+
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
856
|
+
req = [5, level].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
857
|
+
live = false
|
|
858
|
+
@mutex.synchronize do
|
|
859
|
+
write_frame(req)
|
|
860
|
+
r = Reader.new(read_frame)
|
|
861
|
+
tag = r.u8
|
|
862
|
+
case tag
|
|
863
|
+
when 3
|
|
864
|
+
msg = r.text
|
|
865
|
+
raise QueryError, msg.include?("unknown opcode") ? "server does not support streaming: #{msg}" : msg
|
|
866
|
+
when 1, 2
|
|
867
|
+
return nil # not row-producing
|
|
868
|
+
when 5
|
|
869
|
+
# The server has already committed to a row stream, so from here
|
|
870
|
+
# the socket carries frames this call owns — mark it BEFORE
|
|
871
|
+
# parsing the header, not after. `Reader#take` raises on a
|
|
872
|
+
# truncated or absurd column count, and a raise between the
|
|
873
|
+
# commitment and the flag skips the drain entirely: nothing sets
|
|
874
|
+
# `@broken`, `usable?` stays true, and the pool files a socket
|
|
875
|
+
# parked mid-reply back for the next caller, who reads a leftover
|
|
876
|
+
# chunk and gets "unknown response tag 6". That is the very
|
|
877
|
+
# desync the ensure below exists to prevent, reachable through a
|
|
878
|
+
# two-line window.
|
|
879
|
+
live = true
|
|
880
|
+
@streaming = true
|
|
881
|
+
cols = Array.new(r.u32) { r.text }
|
|
882
|
+
begin
|
|
883
|
+
while live
|
|
884
|
+
fr = Reader.new(read_frame)
|
|
885
|
+
case fr.u8
|
|
886
|
+
when 6
|
|
887
|
+
fr.u32.times do
|
|
888
|
+
ncells = fr.u32
|
|
889
|
+
cells = Array.new(ncells) { Skaidb.decode_value(Reader.new(fr.blob)) }
|
|
890
|
+
yield cols.zip(cells).to_h
|
|
891
|
+
end
|
|
892
|
+
when 7
|
|
893
|
+
live = false
|
|
894
|
+
when 3
|
|
895
|
+
live = false
|
|
896
|
+
raise QueryError, fr.text
|
|
897
|
+
else
|
|
898
|
+
# Mid-stream the server sends only RowsChunk, RowsEnd or Error.
|
|
899
|
+
# Anything else means we no longer know where the reply ends, so
|
|
900
|
+
# there is no draining back to a request boundary: retire it.
|
|
901
|
+
live = false
|
|
902
|
+
@broken = true
|
|
903
|
+
raise QueryError, "unexpected frame in stream"
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
ensure
|
|
907
|
+
drain_stream! if live
|
|
908
|
+
# Cleared last: until this point the connection is mid-reply, and a
|
|
909
|
+
# caller who never unwinds here (an abandoned Enumerator Fiber)
|
|
910
|
+
# leaves it set, so usable? keeps reporting the truth.
|
|
911
|
+
@streaming = false
|
|
912
|
+
end
|
|
913
|
+
else
|
|
914
|
+
raise QueryError, "unexpected response tag #{tag} to stream request"
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
nil
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Prepare +sql+ on the SERVER, returning [id, nparams], or nil when the
|
|
921
|
+
# server declines the statement kind (DDL, session statements) so the
|
|
922
|
+
# caller falls back to text binding. Cached per connection.
|
|
923
|
+
def prepare_server(sql)
|
|
924
|
+
hit = @prepared[sql]
|
|
925
|
+
return hit if hit
|
|
926
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
927
|
+
ensure_live!
|
|
928
|
+
|
|
929
|
+
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
930
|
+
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
|
|
936
|
+
tag = reader.u8
|
|
937
|
+
case tag
|
|
938
|
+
when 4
|
|
939
|
+
id = reader.u32
|
|
940
|
+
nparams = reader.u16
|
|
941
|
+
v = [id, nparams]
|
|
942
|
+
@prepared[sql] = v if @prepared.length < 240
|
|
943
|
+
v
|
|
944
|
+
when 3
|
|
945
|
+
# Refused: DDL/session statements cannot be prepared, and an old
|
|
946
|
+
# server answers "unknown opcode". Kept for the caller's error message.
|
|
947
|
+
@last_prepare_error = reader.text
|
|
948
|
+
nil
|
|
949
|
+
else
|
|
950
|
+
raise QueryError, "unexpected prepare response tag #{tag}"
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Execute a prepared statement with TYPED parameters.
|
|
955
|
+
def exec_prepared(id, params, consistency)
|
|
956
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
957
|
+
|
|
958
|
+
req = +([3, consistency].pack("CC") + [id].pack("V") + [params.length].pack("v"))
|
|
959
|
+
params.each do |p|
|
|
960
|
+
v = Skaidb.encode_value(p)
|
|
961
|
+
req << [v.bytesize].pack("V") << v
|
|
962
|
+
end
|
|
963
|
+
reader = nil
|
|
964
|
+
@mutex.synchronize do
|
|
965
|
+
write_frame(req)
|
|
966
|
+
reader = Reader.new(read_frame)
|
|
967
|
+
end
|
|
968
|
+
parse_response(reader)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Close the connection. Idempotent.
|
|
972
|
+
# Yield a stream's events as they arrive, forever.
|
|
973
|
+
#
|
|
974
|
+
# A dependency-free helper over the stream's log: pages it with the
|
|
975
|
+
# keyset cursor and yields each event as a Hash (id, op, k, ts, doc).
|
|
976
|
+
# +id+ is the position — keep the last one and pass it as +after+ to
|
|
977
|
+
# resume exactly where you stopped, across restarts.
|
|
978
|
+
#
|
|
979
|
+
# This polls; for push delivery subscribe to $stream/<db>/<name> with any
|
|
980
|
+
# MQTT client instead. The events are identical.
|
|
981
|
+
#
|
|
982
|
+
# conn.subscribe("big_orders") { |ev| handle(ev["doc"]) }
|
|
983
|
+
def subscribe(stream, after: nil, poll: 0.5)
|
|
984
|
+
log = "_stream_#{stream}"
|
|
985
|
+
cur = after
|
|
986
|
+
loop do
|
|
987
|
+
res = if cur.nil?
|
|
988
|
+
exec("SELECT id, op, k, ts, doc FROM #{log} ORDER BY id LIMIT 500")
|
|
989
|
+
else
|
|
990
|
+
exec_params(
|
|
991
|
+
"SELECT id, op, k, ts, doc FROM #{log} WHERE id > $1 ORDER BY id LIMIT 500",
|
|
992
|
+
[cur]
|
|
993
|
+
)
|
|
994
|
+
end
|
|
995
|
+
rows = res.to_a
|
|
996
|
+
rows.each do |row|
|
|
997
|
+
cur = row["id"]
|
|
998
|
+
yield row
|
|
999
|
+
end
|
|
1000
|
+
sleep(poll) if rows.empty?
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# False once closed, once a transport error broke the socket, or while a
|
|
1005
|
+
# stream is in flight — mid-stream the socket is parked in the middle of a
|
|
1006
|
+
# reply, and a stream abandoned without unwinding never clears the flag, so
|
|
1007
|
+
# this is what stops Pool checking a desynced connection back in.
|
|
1008
|
+
def usable?
|
|
1009
|
+
!@closed && !@broken && !@streaming
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def close
|
|
1013
|
+
return if @closed
|
|
1014
|
+
|
|
1015
|
+
@closed = true
|
|
1016
|
+
begin
|
|
1017
|
+
@sock&.close
|
|
1018
|
+
rescue StandardError
|
|
1019
|
+
# ignore — socket already gone
|
|
1020
|
+
end
|
|
1021
|
+
nil
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def finished?
|
|
1025
|
+
@closed
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
private
|
|
1029
|
+
|
|
1030
|
+
# -- framing --
|
|
1031
|
+
|
|
1032
|
+
def write_frame(payload)
|
|
1033
|
+
payload = payload.b
|
|
1034
|
+
@sock.write([payload.bytesize].pack("N") + payload) # length is BE
|
|
1035
|
+
rescue StandardError => e
|
|
1036
|
+
@broken = true
|
|
1037
|
+
raise ConnectionError, "write failed: #{e.message}"
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# Re-dial if the transport died since the last statement, BEFORE anything
|
|
1041
|
+
# is prepared on it.
|
|
1042
|
+
#
|
|
1043
|
+
# The prepared-statement cache MUST be cleared: an id is only valid on the
|
|
1044
|
+
# connection that created it, so carrying one across a reconnect would run
|
|
1045
|
+
# a different statement (or fail obscurely).
|
|
1046
|
+
def ensure_live!
|
|
1047
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
1048
|
+
return unless @broken
|
|
1049
|
+
|
|
1050
|
+
@prepared.clear
|
|
1051
|
+
begin
|
|
1052
|
+
@sock&.close
|
|
1053
|
+
rescue StandardError
|
|
1054
|
+
nil
|
|
1055
|
+
end
|
|
1056
|
+
@sock = nil
|
|
1057
|
+
# Cleared BEFORE dialling: dial! issues USE, which runs a statement and
|
|
1058
|
+
# would otherwise re-enter this method forever.
|
|
1059
|
+
@broken = false
|
|
1060
|
+
begin
|
|
1061
|
+
dial!
|
|
1062
|
+
rescue StandardError => e
|
|
1063
|
+
@broken = true # still down; the next statement retries
|
|
1064
|
+
raise e
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def read_frame
|
|
1069
|
+
head = read_exact(4)
|
|
1070
|
+
length = head.unpack1("N") # BE
|
|
1071
|
+
read_exact(length)
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# Read out the frames left over from a stream the caller walked away from,
|
|
1075
|
+
# so the connection is positioned at a request boundary again. Deliberately
|
|
1076
|
+
# Runs from an ensure, where an exception would replace whatever the
|
|
1077
|
+
# caller's block was already unwinding with — so an ordinary failure
|
|
1078
|
+
# marks the connection broken rather than raising. usable? then fails
|
|
1079
|
+
# and ensure_live! re-dials before the next statement.
|
|
1080
|
+
#
|
|
1081
|
+
# Not exception-PROOF, and the difference matters: the rescue below
|
|
1082
|
+
# catches StandardError, so Interrupt, SignalException and
|
|
1083
|
+
# NoMemoryError still escape. They also skip the `@streaming = false`
|
|
1084
|
+
# in the caller's ensure, which leaves the connection permanently
|
|
1085
|
+
# unusable — safe, but only because "unusable" is the failing
|
|
1086
|
+
# direction. Rescuing Exception here would hide a Ctrl-C, which is a
|
|
1087
|
+
# worse trade than retiring one connection.
|
|
1088
|
+
#
|
|
1089
|
+
# There is also no read DEADLINE. A peer that is alive but silent
|
|
1090
|
+
# blocks the drain forever, holding @mutex — the same as every other
|
|
1091
|
+
# read in this driver, and `close` deliberately does not take @mutex
|
|
1092
|
+
# so another thread can break it. A genuinely dead socket (EOF, RST)
|
|
1093
|
+
# exits correctly.
|
|
1094
|
+
def drain_stream!
|
|
1095
|
+
loop do
|
|
1096
|
+
tag = Reader.new(read_frame).u8
|
|
1097
|
+
break if tag == 7 || tag == 3 # RowsEnd, or an Error that ended the stream
|
|
1098
|
+
next if tag == 6 # RowsChunk: more to come
|
|
1099
|
+
|
|
1100
|
+
@broken = true
|
|
1101
|
+
break
|
|
1102
|
+
end
|
|
1103
|
+
rescue StandardError
|
|
1104
|
+
# An I/O failure already set @broken in read_exact; set it for the rest
|
|
1105
|
+
# (a truncated frame, say) so the socket is never reused mid-reply.
|
|
1106
|
+
@broken = true
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def read_exact(n)
|
|
1110
|
+
return "".b if n.zero?
|
|
1111
|
+
|
|
1112
|
+
buf = String.new(capacity: n) # ASCII-8BIT, so appending raw bytes never re-encodes
|
|
1113
|
+
while buf.bytesize < n
|
|
1114
|
+
chunk = begin
|
|
1115
|
+
@sock.read(n - buf.bytesize)
|
|
1116
|
+
rescue StandardError => e
|
|
1117
|
+
@broken = true
|
|
1118
|
+
raise ConnectionError, "read failed: #{e.message}"
|
|
1119
|
+
end
|
|
1120
|
+
if chunk.nil? || chunk.empty?
|
|
1121
|
+
@broken = true
|
|
1122
|
+
raise ConnectionError, "connection closed by server"
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
buf << chunk
|
|
1126
|
+
end
|
|
1127
|
+
buf
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# -- handshake --
|
|
1131
|
+
|
|
1132
|
+
def handshake(user, password)
|
|
1133
|
+
counter = Skaidb.next_nonce_id
|
|
1134
|
+
client_nonce = "rb#{Process.pid}.#{counter}.#{SecureRandom.hex(4)}"
|
|
1135
|
+
|
|
1136
|
+
start = [10].pack("C") + Skaidb.enc_str(user) + Skaidb.enc_str(client_nonce)
|
|
1137
|
+
write_frame(start)
|
|
1138
|
+
|
|
1139
|
+
r = Reader.new(read_frame)
|
|
1140
|
+
raise ConnectionError, "bad handshake challenge" unless r.u8 == 11
|
|
1141
|
+
|
|
1142
|
+
salt = r.blob
|
|
1143
|
+
iterations = r.u32
|
|
1144
|
+
server_nonce = r.text
|
|
1145
|
+
|
|
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")
|
|
1149
|
+
proof, expected_sig = Skaidb.scram(password, salt, iterations, auth_message)
|
|
1150
|
+
|
|
1151
|
+
write_frame([12].pack("C") + proof) # 32 raw bytes, not length-prefixed
|
|
1152
|
+
|
|
1153
|
+
r = Reader.new(read_frame)
|
|
1154
|
+
raise ConnectionError, "bad handshake outcome" unless r.u8 == 13
|
|
1155
|
+
|
|
1156
|
+
if r.u8 == 1
|
|
1157
|
+
server_sig = r.take(32)
|
|
1158
|
+
if !password.empty? && !Skaidb.secure_compare(server_sig, expected_sig)
|
|
1159
|
+
raise ConnectionError, "server signature mismatch (mutual auth failed)"
|
|
1160
|
+
end
|
|
1161
|
+
else
|
|
1162
|
+
raise ConnectionError, "authentication denied: #{r.text}"
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# -- query --
|
|
1167
|
+
|
|
1168
|
+
def run(sql, consistency)
|
|
1169
|
+
raise ConnectionError, "connection is closed" if @closed
|
|
1170
|
+
ensure_live!
|
|
1171
|
+
|
|
1172
|
+
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
1173
|
+
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)
|
|
1178
|
+
end
|
|
1179
|
+
parse_response(reader)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def parse_response(r)
|
|
1183
|
+
tag = r.u8
|
|
1184
|
+
case tag
|
|
1185
|
+
when 0 # Rows
|
|
1186
|
+
ncols = r.u32
|
|
1187
|
+
columns = Array.new(ncols) { r.text }
|
|
1188
|
+
nrows = r.u32
|
|
1189
|
+
rows = Array.new(nrows) do
|
|
1190
|
+
ncells = r.u32
|
|
1191
|
+
Array.new(ncells) { Skaidb.decode_value(Reader.new(r.blob)) }
|
|
1192
|
+
end
|
|
1193
|
+
Result.new(fields: columns, rows: rows, cmd_tuples: 0)
|
|
1194
|
+
when 8 # ResultSets: a CALL whose body EMITted
|
|
1195
|
+
sets = Array.new(r.u32) do
|
|
1196
|
+
ncols = r.u32
|
|
1197
|
+
columns = Array.new(ncols) { r.text }
|
|
1198
|
+
nrows = r.u32
|
|
1199
|
+
rows = Array.new(nrows) do
|
|
1200
|
+
ncells = r.u32
|
|
1201
|
+
Array.new(ncells) { Skaidb.decode_value(Reader.new(r.blob)) }
|
|
1202
|
+
end
|
|
1203
|
+
Result.new(fields: columns, rows: rows, cmd_tuples: 0)
|
|
1204
|
+
end
|
|
1205
|
+
last = sets.last || Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1206
|
+
Result.new(fields: last.fields, rows: last.rows, cmd_tuples: 0, result_sets: sets)
|
|
1207
|
+
when 1 # Mutation
|
|
1208
|
+
Result.new(fields: [], rows: [], cmd_tuples: r.u64)
|
|
1209
|
+
when 2 # Ddl
|
|
1210
|
+
Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1211
|
+
when 3 # Error — a statement error, connection stays usable
|
|
1212
|
+
raise QueryError, r.text
|
|
1213
|
+
else
|
|
1214
|
+
raise ConnectionError, "unknown response tag #{tag}"
|
|
1215
|
+
end
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# ---- module entry point --------------------------------------------------
|
|
1220
|
+
|
|
1221
|
+
# Open a connection to a skaidb node and run the SCRAM-SHA-256 handshake.
|
|
1222
|
+
#
|
|
1223
|
+
# @param host [String]
|
|
1224
|
+
# @param port [Integer]
|
|
1225
|
+
# @param user [String]
|
|
1226
|
+
# @param password [String]
|
|
1227
|
+
# @param consistency [Symbol, String, Integer] :one / :quorum / :all (or 0/1/2)
|
|
1228
|
+
# @param timeout [Numeric, nil] connect/IO timeout in seconds
|
|
1229
|
+
# @yield [conn] optional block; the connection is closed when it returns
|
|
1230
|
+
# @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)
|
|
1241
|
+
return conn unless block_given?
|
|
1242
|
+
|
|
1243
|
+
begin
|
|
1244
|
+
yield conn
|
|
1245
|
+
ensure
|
|
1246
|
+
conn.close
|
|
1247
|
+
end
|
|
1248
|
+
end
|
|
1249
|
+
# A thread-safe pool of connections.
|
|
1250
|
+
#
|
|
1251
|
+
# +maxsize+ bounds the connections kept IDLE, not the number checked out: a
|
|
1252
|
+
# 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.
|
|
1255
|
+
#
|
|
1256
|
+
# pool = Skaidb::Pool.new(seeds: ["h1:7000", "h2:7000"], database: "app", maxsize: 8)
|
|
1257
|
+
# pool.with { |conn| conn.exec("SELECT 1") }
|
|
1258
|
+
# pool.close
|
|
1259
|
+
class Pool
|
|
1260
|
+
def initialize(maxsize: 10, **connect_kwargs)
|
|
1261
|
+
raise ArgumentError, "maxsize must be >= 1" if maxsize < 1
|
|
1262
|
+
|
|
1263
|
+
@maxsize = maxsize
|
|
1264
|
+
@kwargs = connect_kwargs
|
|
1265
|
+
@idle = []
|
|
1266
|
+
@mutex = Mutex.new
|
|
1267
|
+
@closed = false
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
# Check out a usable connection, reusing an idle one when possible.
|
|
1271
|
+
def checkout
|
|
1272
|
+
loop do
|
|
1273
|
+
conn = @mutex.synchronize do
|
|
1274
|
+
raise Error, "pool is closed" if @closed
|
|
1275
|
+
|
|
1276
|
+
@idle.pop
|
|
1277
|
+
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
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# Return a connection, closing it if broken or the pool is full.
|
|
1292
|
+
def checkin(conn)
|
|
1293
|
+
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
|
|
1304
|
+
end
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
# Run the block with a checked-out connection, returning it afterwards.
|
|
1308
|
+
def with
|
|
1309
|
+
conn = checkout
|
|
1310
|
+
begin
|
|
1311
|
+
yield conn
|
|
1312
|
+
ensure
|
|
1313
|
+
checkin(conn)
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
# Close the pool and every idle connection.
|
|
1318
|
+
def close
|
|
1319
|
+
drained = @mutex.synchronize do
|
|
1320
|
+
@closed = true
|
|
1321
|
+
d = @idle
|
|
1322
|
+
@idle = []
|
|
1323
|
+
d
|
|
1324
|
+
end
|
|
1325
|
+
drained.each do |c|
|
|
1326
|
+
begin
|
|
1327
|
+
c.close
|
|
1328
|
+
rescue StandardError
|
|
1329
|
+
nil
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
end
|
|
1333
|
+
end
|
|
1334
|
+
end
|