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