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