tricoredb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE +201 -0
- data/README.md +377 -0
- data/lib/tricoredb/builders.rb +142 -0
- data/lib/tricoredb/client.rb +979 -0
- data/lib/tricoredb/errors.rb +99 -0
- data/lib/tricoredb/frame.rb +106 -0
- data/lib/tricoredb/params.rb +121 -0
- data/lib/tricoredb/pool.rb +159 -0
- data/lib/tricoredb/response.rb +131 -0
- data/lib/tricoredb/transport.rb +184 -0
- data/lib/tricoredb/version.rb +6 -0
- data/lib/tricoredb.rb +21 -0
- data/tricoredb.gemspec +30 -0
- metadata +92 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TriCoreDB
|
|
4
|
+
# The code a not-leader refusal carries in `diagnostics.error_code`.
|
|
5
|
+
NOT_LEADER = "not_leader"
|
|
6
|
+
|
|
7
|
+
# Base class for every failure raised by this gem.
|
|
8
|
+
#
|
|
9
|
+
# Branch on {#code}, never on the message: the message is prose the server is
|
|
10
|
+
# free to reword, the code is contract.
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
# @return [String, nil] the machine-readable reason, when the server sent one
|
|
13
|
+
attr_reader :code
|
|
14
|
+
# @return [String, nil] a `host:port` of the current leader, only alongside `not_leader`
|
|
15
|
+
attr_reader :leader_hint
|
|
16
|
+
|
|
17
|
+
# @param message [String, nil]
|
|
18
|
+
# @param code [String, nil]
|
|
19
|
+
# @param leader_hint [String, nil]
|
|
20
|
+
def initialize(message = nil, code: nil, leader_hint: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@code = code
|
|
23
|
+
@leader_hint = leader_hint
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether the request was right but reached a node that is not the leader.
|
|
27
|
+
#
|
|
28
|
+
# The driver never follows {#leader_hint} on its own: the address may not be
|
|
29
|
+
# reachable from this client, a new connection must authenticate again, and
|
|
30
|
+
# an open session transaction cannot move to another node at all.
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def redirect?
|
|
34
|
+
code == NOT_LEADER
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The server refused the credentials (an AUTH_OK frame with `ok: false`).
|
|
39
|
+
class AuthError < Error; end
|
|
40
|
+
|
|
41
|
+
# The byte stream can no longer be trusted: a bad header, an oversized frame,
|
|
42
|
+
# an unexpected tag, or a refused handshake. The connection is closed.
|
|
43
|
+
class ProtocolError < Error; end
|
|
44
|
+
|
|
45
|
+
# The socket failed or was closed. The connection is unusable.
|
|
46
|
+
class ConnectionError < Error; end
|
|
47
|
+
|
|
48
|
+
# No reply arrived within the connection's `read_timeout`. The connection is
|
|
49
|
+
# closed, because the late reply would otherwise be read as the answer to the
|
|
50
|
+
# next request.
|
|
51
|
+
class ReadTimeout < ConnectionError; end
|
|
52
|
+
|
|
53
|
+
# The server processed the request and did not complete it: a RESPONSE whose
|
|
54
|
+
# status is not `ok`, or an ERROR frame answering a request.
|
|
55
|
+
class ServerError < Error
|
|
56
|
+
# @return [String, nil] `error` or `not_implemented` for a RESPONSE; nil for an ERROR frame
|
|
57
|
+
attr_reader :status
|
|
58
|
+
# @return [Response, nil] the full response, for its diagnostics
|
|
59
|
+
attr_reader :response
|
|
60
|
+
|
|
61
|
+
def initialize(message = nil, code: nil, leader_hint: nil, status: nil, response: nil, frame: false)
|
|
62
|
+
super(message, code: code, leader_hint: leader_hint)
|
|
63
|
+
@status = status
|
|
64
|
+
@response = response
|
|
65
|
+
@frame = frame
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] true when the refusal arrived as an ERROR frame rather than a RESPONSE
|
|
69
|
+
def frame?
|
|
70
|
+
@frame
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The server did not grant a capability this call needs. Raised before
|
|
75
|
+
# anything is sent.
|
|
76
|
+
class FeatureNotGranted < Error
|
|
77
|
+
# @return [String] the feature name, e.g. `SERVER_PARAMS`
|
|
78
|
+
attr_reader :feature
|
|
79
|
+
|
|
80
|
+
def initialize(feature, message)
|
|
81
|
+
super(message)
|
|
82
|
+
@feature = feature
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A SQL parameter this driver will not encode. Raised before anything is sent.
|
|
87
|
+
class ParameterError < Error
|
|
88
|
+
# @return [Integer] the 1-based parameter position
|
|
89
|
+
attr_reader :index
|
|
90
|
+
|
|
91
|
+
def initialize(index, message)
|
|
92
|
+
super("parameter ##{index}: #{message}")
|
|
93
|
+
@index = index
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# {Pool#with} waited longer than its checkout timeout.
|
|
98
|
+
class PoolTimeout < Error; end
|
|
99
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TriCoreDB
|
|
6
|
+
# The native frame format: `version:u8 | tag:u8 | payload_len:u32be | payload`.
|
|
7
|
+
module Frame
|
|
8
|
+
# The frame format version this driver writes.
|
|
9
|
+
VERSION = 1
|
|
10
|
+
# The highest frame format version this driver can read.
|
|
11
|
+
MAX_SUPPORTED_VERSION = 1
|
|
12
|
+
HEADER_SIZE = 6
|
|
13
|
+
# Ceiling for REQUEST and RESPONSE payloads.
|
|
14
|
+
MAX_DATA_PAYLOAD = 16 * 1024 * 1024
|
|
15
|
+
# Ceiling for every other frame.
|
|
16
|
+
MAX_CONTROL_PAYLOAD = 64 * 1024
|
|
17
|
+
|
|
18
|
+
HELLO = 0
|
|
19
|
+
AUTH = 1
|
|
20
|
+
REQUEST = 2
|
|
21
|
+
RESPONSE = 3
|
|
22
|
+
PING = 4
|
|
23
|
+
PONG = 5
|
|
24
|
+
ERROR = 6
|
|
25
|
+
CLOSE = 7
|
|
26
|
+
HELLO_OK = 8
|
|
27
|
+
AUTH_OK = 9
|
|
28
|
+
BYE = 10
|
|
29
|
+
CANCEL = 11
|
|
30
|
+
CANCEL_OK = 12
|
|
31
|
+
|
|
32
|
+
NAMES = {
|
|
33
|
+
HELLO => "HELLO", AUTH => "AUTH", REQUEST => "REQUEST", RESPONSE => "RESPONSE",
|
|
34
|
+
PING => "PING", PONG => "PONG", ERROR => "ERROR", CLOSE => "CLOSE",
|
|
35
|
+
HELLO_OK => "HELLO_OK", AUTH_OK => "AUTH_OK", BYE => "BYE",
|
|
36
|
+
CANCEL => "CANCEL", CANCEL_OK => "CANCEL_OK"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param tag [Integer]
|
|
42
|
+
# @return [Integer] the payload ceiling for that tag; an unknown tag gets the tighter one
|
|
43
|
+
def max_payload_for(tag)
|
|
44
|
+
tag == REQUEST || tag == RESPONSE ? MAX_DATA_PAYLOAD : MAX_CONTROL_PAYLOAD
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param tag [Integer]
|
|
48
|
+
# @return [String]
|
|
49
|
+
def name(tag)
|
|
50
|
+
NAMES.fetch(tag) { "tag #{tag}" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Encode one frame.
|
|
54
|
+
#
|
|
55
|
+
# @param tag [Integer]
|
|
56
|
+
# @param payload [Object, nil] JSON-serialisable; nil sends an empty payload
|
|
57
|
+
# @return [String] binary frame bytes
|
|
58
|
+
# @raise [ProtocolError] when the payload exceeds the ceiling for its tag
|
|
59
|
+
def encode(tag, payload)
|
|
60
|
+
body = payload.nil? ? "".b : JSON.generate(payload).b
|
|
61
|
+
limit = max_payload_for(tag)
|
|
62
|
+
if body.bytesize > limit
|
|
63
|
+
raise ProtocolError.new(
|
|
64
|
+
"refusing to send a #{body.bytesize}-byte #{name(tag)} payload; the protocol caps it at #{limit} bytes",
|
|
65
|
+
code: "frame_too_large"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
[VERSION, tag, body.bytesize].pack("CCN") + body
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Decode and validate a six-byte header before any payload byte is read.
|
|
72
|
+
#
|
|
73
|
+
# @param header [String] exactly {HEADER_SIZE} bytes
|
|
74
|
+
# @return [Array(Integer, Integer)] `[tag, payload_length]`
|
|
75
|
+
# @raise [ProtocolError]
|
|
76
|
+
def decode_header(header)
|
|
77
|
+
raise ProtocolError, "short frame header (#{header.bytesize} bytes)" if header.bytesize != HEADER_SIZE
|
|
78
|
+
|
|
79
|
+
version, tag, length = header.unpack("CCN")
|
|
80
|
+
if version > MAX_SUPPORTED_VERSION
|
|
81
|
+
raise ProtocolError.new(
|
|
82
|
+
"frame header version #{version} is newer than this driver can read (max #{MAX_SUPPORTED_VERSION})",
|
|
83
|
+
code: "frame_version"
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
limit = max_payload_for(tag)
|
|
87
|
+
if length > limit
|
|
88
|
+
raise ProtocolError.new(
|
|
89
|
+
"#{name(tag)} frame declares a #{length}-byte payload, above the #{limit}-byte limit; refusing to buffer it",
|
|
90
|
+
code: "frame_too_large"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
[tag, length]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param body [String]
|
|
97
|
+
# @return [Object, nil] the parsed JSON, or nil for an empty payload
|
|
98
|
+
def decode_body(body)
|
|
99
|
+
return nil if body.empty?
|
|
100
|
+
|
|
101
|
+
JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
|
|
102
|
+
rescue JSON::ParserError => e
|
|
103
|
+
raise ProtocolError, "frame payload is not valid JSON: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "bigdecimal"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# bigdecimal is a bundled gem from Ruby 3.4; without it, BigDecimal values simply cannot occur.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module TriCoreDB
|
|
12
|
+
# Explicit marker for bytes bound to a BLOB column, whatever the String's encoding.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# db.execute("INSERT INTO files VALUES (?, ?)", [1, TriCoreDB::Binary.new(File.binread("a.png"))])
|
|
16
|
+
class Binary
|
|
17
|
+
# @return [String] the bytes, as a binary (ASCII-8BIT) String
|
|
18
|
+
attr_reader :bytes
|
|
19
|
+
|
|
20
|
+
# @param bytes [String, Array<Integer>]
|
|
21
|
+
def initialize(bytes)
|
|
22
|
+
@bytes = bytes.is_a?(Array) ? bytes.pack("C*") : String(bytes).b
|
|
23
|
+
@bytes.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] the `0x…` hex text a BLOB column parses
|
|
27
|
+
def to_param
|
|
28
|
+
"0x#{@bytes.unpack1('H*')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(Binary) && other.bytes == bytes
|
|
33
|
+
end
|
|
34
|
+
alias eql? ==
|
|
35
|
+
|
|
36
|
+
def hash
|
|
37
|
+
bytes.hash
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param bytes [String, Array<Integer>]
|
|
42
|
+
# @return [Binary]
|
|
43
|
+
def self.binary(bytes)
|
|
44
|
+
Binary.new(bytes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Conversion of Ruby values to server-side SQL parameters.
|
|
48
|
+
#
|
|
49
|
+
# The wire carries JSON scalars only. The mapping:
|
|
50
|
+
#
|
|
51
|
+
# - `nil`, `true`, `false` as themselves
|
|
52
|
+
# - `Integer` as an exact JSON number, Bignum included (the server refuses anything outside i64 by name)
|
|
53
|
+
# - `Float` as a JSON number; NaN and infinities are refused
|
|
54
|
+
# - `String` in **binary encoding (ASCII-8BIT)** as `0x` + hex, for a BLOB column
|
|
55
|
+
# - any other `String` as UTF-8 text; invalid UTF-8 is refused
|
|
56
|
+
# - {Binary} as `0x` + hex, whatever the String's encoding was
|
|
57
|
+
# - `BigDecimal` as plain decimal text with no exponent (`to_s("F")`); non-finite values are refused
|
|
58
|
+
# - `Time` as ISO-8601 with microseconds; `Date`/`DateTime` as ISO-8601
|
|
59
|
+
#
|
|
60
|
+
# Everything else is refused by name rather than sent through `to_s`.
|
|
61
|
+
module Params
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# @param params [Array]
|
|
65
|
+
# @return [Array] wire values
|
|
66
|
+
def encode_all(params)
|
|
67
|
+
raise ArgumentError, "SQL parameters must be an Array, got #{params.class}" unless params.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
params.each_with_index.map { |value, i| encode(value, i + 1) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param value [Object]
|
|
73
|
+
# @param index [Integer] 1-based position, for the error message
|
|
74
|
+
# @return [nil, true, false, Integer, Float, String]
|
|
75
|
+
def encode(value, index = 1)
|
|
76
|
+
case value
|
|
77
|
+
when nil, true, false, Integer
|
|
78
|
+
value
|
|
79
|
+
when Float
|
|
80
|
+
raise ParameterError.new(index, "#{value} is not a finite number and has no SQL value") unless value.finite?
|
|
81
|
+
|
|
82
|
+
value
|
|
83
|
+
when Binary
|
|
84
|
+
value.to_param
|
|
85
|
+
when String
|
|
86
|
+
encode_string(value, index)
|
|
87
|
+
when Time
|
|
88
|
+
value.iso8601(6)
|
|
89
|
+
else
|
|
90
|
+
encode_other(value, index)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def encode_string(value, index)
|
|
95
|
+
return "0x#{value.unpack1('H*')}" if value.encoding == Encoding::BINARY
|
|
96
|
+
|
|
97
|
+
text = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
98
|
+
raise ParameterError.new(index, "string is not valid UTF-8; mark bytes with TriCoreDB::Binary or String#b") unless text.valid_encoding?
|
|
99
|
+
|
|
100
|
+
text
|
|
101
|
+
rescue EncodingError => e
|
|
102
|
+
raise ParameterError.new(index, "string cannot be converted to UTF-8 (#{e.message})")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def encode_other(value, index)
|
|
106
|
+
if defined?(::BigDecimal) && value.is_a?(::BigDecimal)
|
|
107
|
+
raise ParameterError.new(index, "BigDecimal #{value} is not finite and has no SQL value") unless value.finite?
|
|
108
|
+
|
|
109
|
+
return value.to_s("F")
|
|
110
|
+
end
|
|
111
|
+
return value.iso8601 if defined?(::Date) && value.is_a?(::Date)
|
|
112
|
+
|
|
113
|
+
raise ParameterError.new(
|
|
114
|
+
index,
|
|
115
|
+
"no SQL parameter form for #{value.class}. Convert it explicitly (a String, Integer, Float, " \
|
|
116
|
+
"BigDecimal, Time, TriCoreDB::Binary, true/false or nil)"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
private_class_method :encode_string, :encode_other
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TriCoreDB
|
|
4
|
+
# A thread-safe pool of connections.
|
|
5
|
+
#
|
|
6
|
+
# A connection is lent to exactly one block at a time. Connections are
|
|
7
|
+
# created lazily up to `size`.
|
|
8
|
+
#
|
|
9
|
+
# A session transaction is bound to its connection, so a connection is never
|
|
10
|
+
# returned mid-transaction: a block that raises with one open has it rolled
|
|
11
|
+
# back, and a block that returns with one open has it rolled back and raises.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# pool = TriCoreDB::Pool.new(size: 8, host: "db", port: 8427, user: "app", secret: ENV["TRICORE_SECRET"])
|
|
15
|
+
# pool.with { |db| db.query("SELECT 1") }
|
|
16
|
+
# pool.close
|
|
17
|
+
class Pool
|
|
18
|
+
# @return [Integer]
|
|
19
|
+
attr_reader :size
|
|
20
|
+
|
|
21
|
+
# @param size [Integer] maximum connections
|
|
22
|
+
# @param checkout_timeout [Numeric] seconds {#with} waits for a free connection
|
|
23
|
+
# @param connect_options [Hash] passed to {Client.connect}, `tls:` included
|
|
24
|
+
def initialize(size: 8, checkout_timeout: 10, **connect_options)
|
|
25
|
+
raise ArgumentError, "pool size must be >= 1" if size < 1
|
|
26
|
+
|
|
27
|
+
@size = size
|
|
28
|
+
@checkout_timeout = checkout_timeout
|
|
29
|
+
@connect_options = connect_options
|
|
30
|
+
@idle = []
|
|
31
|
+
@created = 0
|
|
32
|
+
@waiting = 0
|
|
33
|
+
@closed = false
|
|
34
|
+
@lock = Mutex.new
|
|
35
|
+
@available = ConditionVariable.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Borrow a connection for the duration of the block.
|
|
39
|
+
#
|
|
40
|
+
# @yieldparam db [Client]
|
|
41
|
+
# @return [Object] the block's value
|
|
42
|
+
# @raise [PoolTimeout] when none frees up within the timeout
|
|
43
|
+
def with(timeout: @checkout_timeout)
|
|
44
|
+
raise ArgumentError, "Pool#with needs a block" unless block_given?
|
|
45
|
+
|
|
46
|
+
client = checkout(timeout)
|
|
47
|
+
broken = false
|
|
48
|
+
begin
|
|
49
|
+
begin
|
|
50
|
+
result = yield client
|
|
51
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
52
|
+
if client.closed? || stream_broken?(e)
|
|
53
|
+
broken = true
|
|
54
|
+
elsif client.in_transaction?
|
|
55
|
+
broken = !abandon_transaction(client)
|
|
56
|
+
end
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
if client.in_transaction?
|
|
60
|
+
broken = !abandon_transaction(client)
|
|
61
|
+
raise Error, "the block returned with a session transaction still open on the pooled connection; it " \
|
|
62
|
+
"has been rolled back rather than returned to the pool. Commit or roll back inside the " \
|
|
63
|
+
"block, or use db.transaction { }"
|
|
64
|
+
end
|
|
65
|
+
result
|
|
66
|
+
ensure
|
|
67
|
+
checkin(client, broken)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Hash{Symbol => Integer}]
|
|
72
|
+
def stats
|
|
73
|
+
@lock.synchronize do
|
|
74
|
+
{ size: @size, created: @created, idle: @idle.size, in_use: @created - @idle.size, waiting: @waiting }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Close idle connections and refuse further checkouts. Connections in use are
|
|
79
|
+
# closed when they are returned.
|
|
80
|
+
def close
|
|
81
|
+
idle = @lock.synchronize do
|
|
82
|
+
@closed = true
|
|
83
|
+
@available.broadcast
|
|
84
|
+
list = @idle.dup
|
|
85
|
+
@idle.clear
|
|
86
|
+
@created -= list.size
|
|
87
|
+
list
|
|
88
|
+
end
|
|
89
|
+
idle.each(&:close)
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def stream_broken?(error)
|
|
96
|
+
case error
|
|
97
|
+
when ConnectionError, ProtocolError then true
|
|
98
|
+
when ServerError then error.frame?
|
|
99
|
+
else false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def abandon_transaction(client)
|
|
104
|
+
client.rollback
|
|
105
|
+
true
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def checkout(timeout)
|
|
111
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
112
|
+
@lock.synchronize do
|
|
113
|
+
loop do
|
|
114
|
+
raise Error, "pool is closed" if @closed
|
|
115
|
+
|
|
116
|
+
return @idle.pop unless @idle.empty?
|
|
117
|
+
|
|
118
|
+
if @created < @size
|
|
119
|
+
@created += 1
|
|
120
|
+
break
|
|
121
|
+
end
|
|
122
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
raise PoolTimeout, "no pooled connection available within #{timeout}s (size=#{@size})" if remaining <= 0
|
|
124
|
+
|
|
125
|
+
@waiting += 1
|
|
126
|
+
begin
|
|
127
|
+
@available.wait(@lock, remaining)
|
|
128
|
+
ensure
|
|
129
|
+
@waiting -= 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
begin
|
|
134
|
+
Client.connect(**@connect_options)
|
|
135
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
136
|
+
@lock.synchronize do
|
|
137
|
+
@created -= 1
|
|
138
|
+
@available.signal
|
|
139
|
+
end
|
|
140
|
+
raise
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def checkin(client, broken)
|
|
145
|
+
discard = @lock.synchronize do
|
|
146
|
+
if broken || @closed || client.closed? || client.in_transaction?
|
|
147
|
+
@created -= 1
|
|
148
|
+
@available.signal
|
|
149
|
+
true
|
|
150
|
+
else
|
|
151
|
+
@idle.push(client)
|
|
152
|
+
@available.signal
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
client.close if discard
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TriCoreDB
|
|
6
|
+
# A SQL result set. Every cell is the server's text rendering of the value.
|
|
7
|
+
class Rows
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
# @return [Array<String>]
|
|
11
|
+
attr_reader :columns
|
|
12
|
+
# @return [Array<Array<String, nil>>]
|
|
13
|
+
attr_reader :rows
|
|
14
|
+
|
|
15
|
+
def initialize(columns, rows)
|
|
16
|
+
@columns = columns
|
|
17
|
+
@rows = rows
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each(&block)
|
|
21
|
+
return enum_for(:each) unless block
|
|
22
|
+
|
|
23
|
+
@rows.each(&block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
def size
|
|
28
|
+
@rows.size
|
|
29
|
+
end
|
|
30
|
+
alias length size
|
|
31
|
+
|
|
32
|
+
# @return [Array<Hash{String => String}>] rows keyed by column name
|
|
33
|
+
def to_hashes
|
|
34
|
+
@rows.map { |r| @columns.zip(r).to_h }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# One entry of a cache stream.
|
|
39
|
+
StreamEntry = Struct.new(:id, :fields) do
|
|
40
|
+
# @return [Hash{String => String}] fields decoded as UTF-8, for the all-text case
|
|
41
|
+
def text
|
|
42
|
+
fields.to_h { |f, v| [f.dup.force_encoding(Encoding::UTF_8), v.dup.force_encoding(Encoding::UTF_8)] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A server RESPONSE: typed data plus how it was produced.
|
|
47
|
+
class Response
|
|
48
|
+
# @return [String]
|
|
49
|
+
attr_reader :request_id
|
|
50
|
+
# @return [String] `ok`, `error` or `not_implemented`
|
|
51
|
+
attr_reader :status
|
|
52
|
+
# @return [Object] the externally tagged ResponseData (`"Empty"`, `{"Json" => ...}`, ...)
|
|
53
|
+
attr_reader :data
|
|
54
|
+
# @return [Hash]
|
|
55
|
+
attr_reader :diagnostics
|
|
56
|
+
|
|
57
|
+
# @param raw [Hash] the decoded RESPONSE payload
|
|
58
|
+
def initialize(raw)
|
|
59
|
+
raw = {} unless raw.is_a?(Hash)
|
|
60
|
+
@request_id = raw["request_id"].to_s
|
|
61
|
+
@status = raw["status"] || "error"
|
|
62
|
+
@data = raw["data"]
|
|
63
|
+
@diagnostics = raw["diagnostics"].is_a?(Hash) ? raw["diagnostics"] : {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ok?
|
|
67
|
+
@status == "ok"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Array<String>] non-fatal warnings; a partially applied broadcast reports here while still `ok`
|
|
71
|
+
def warnings
|
|
72
|
+
@diagnostics["warnings"] || []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [String, nil]
|
|
76
|
+
def error_code
|
|
77
|
+
@diagnostics["error_code"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [String, nil]
|
|
81
|
+
def leader_hint
|
|
82
|
+
@diagnostics["leader_hint"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def redirect?
|
|
86
|
+
error_code == NOT_LEADER
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Integer, nil] `rows_affected` from a SQL write, when the server reported it
|
|
90
|
+
def rows_affected
|
|
91
|
+
json = arm("Json")
|
|
92
|
+
json.is_a?(Hash) && json["rows_affected"].is_a?(Integer) ? json["rows_affected"] : nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Whether the data carries the named arm (`Json`, `Rows`, ...). A `null` value still counts.
|
|
96
|
+
def arm?(name)
|
|
97
|
+
@data.is_a?(Hash) && @data.key?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Object, nil]
|
|
101
|
+
def arm(name)
|
|
102
|
+
@data.is_a?(Hash) ? @data[name] : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [String] the kind of data, for messages
|
|
106
|
+
def kind
|
|
107
|
+
@data.is_a?(Hash) ? (@data.keys.first || "{}") : @data.inspect
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Build the error for a response that is not `ok`.
|
|
111
|
+
#
|
|
112
|
+
# @param txn_open [Boolean] whether a session transaction was open on the connection
|
|
113
|
+
# @return [ServerError]
|
|
114
|
+
def to_error(txn_open = false)
|
|
115
|
+
text = arm("Message") || (arm?("Json") ? JSON.generate(arm("Json")) : nil) || "request failed"
|
|
116
|
+
message = "#{text} (server status: #{@status})"
|
|
117
|
+
if redirect?
|
|
118
|
+
message += if leader_hint
|
|
119
|
+
" [not_leader: the leader serves clients at `#{leader_hint}`. This driver does not follow " \
|
|
120
|
+
"the hint on its own; send the request there."
|
|
121
|
+
else
|
|
122
|
+
" [not_leader: there is no leader address to name (an election is in progress, or the " \
|
|
123
|
+
"leader has no address configured). Wait and try again."
|
|
124
|
+
end
|
|
125
|
+
message += " The open session transaction is over: it cannot continue on another node." if txn_open
|
|
126
|
+
message += "]"
|
|
127
|
+
end
|
|
128
|
+
ServerError.new(message, code: error_code, leader_hint: leader_hint, status: @status, response: self)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|