questdb 0.1.0-aarch64-linux-gnu

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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE.txt +201 -0
  4. data/README.md +170 -0
  5. data/SECURITY.md +19 -0
  6. data/THIRD_PARTY_NOTICES.md +242 -0
  7. data/docs/arrow.md +89 -0
  8. data/docs/configuration.md +61 -0
  9. data/docs/design.md +46 -0
  10. data/docs/ingestion.md +96 -0
  11. data/docs/performance.md +36 -0
  12. data/docs/python-parity.md +34 -0
  13. data/docs/querying.md +54 -0
  14. data/docs/release-infrastructure.md +70 -0
  15. data/docs/releasing.md +47 -0
  16. data/docs/troubleshooting.md +41 -0
  17. data/examples/basic.rb +32 -0
  18. data/examples/callbacks.rb +46 -0
  19. data/examples/columnar.rb +41 -0
  20. data/examples/independent_buffer.rb +39 -0
  21. data/examples/query.rb +28 -0
  22. data/examples/standalone.rb +33 -0
  23. data/examples/tls_auth.rb +33 -0
  24. data/lib/questdb/arrow.rb +250 -0
  25. data/lib/questdb/client.rb +308 -0
  26. data/lib/questdb/column_vector.rb +91 -0
  27. data/lib/questdb/config.rb +161 -0
  28. data/lib/questdb/connection_event.rb +60 -0
  29. data/lib/questdb/direct_ingestion.rb +440 -0
  30. data/lib/questdb/event_inbox.rb +60 -0
  31. data/lib/questdb/ingress/buffer.rb +104 -0
  32. data/lib/questdb/ingress/error.rb +43 -0
  33. data/lib/questdb/ingress/float64_array.rb +60 -0
  34. data/lib/questdb/ingress/native.rb +40 -0
  35. data/lib/questdb/ingress/timestamp.rb +53 -0
  36. data/lib/questdb/ingress.rb +77 -0
  37. data/lib/questdb/native/3.1/questdb_ingress.so +0 -0
  38. data/lib/questdb/native/3.2/questdb_ingress.so +0 -0
  39. data/lib/questdb/native/3.3/questdb_ingress.so +0 -0
  40. data/lib/questdb/native/3.4/questdb_ingress.so +0 -0
  41. data/lib/questdb/pooled_sender.rb +215 -0
  42. data/lib/questdb/query.rb +429 -0
  43. data/lib/questdb/sender.rb +436 -0
  44. data/lib/questdb/sender_error.rb +49 -0
  45. data/lib/questdb/types.rb +198 -0
  46. data/lib/questdb/version.rb +27 -0
  47. data/lib/questdb.rb +22 -0
  48. data/questdb.gemspec +69 -0
  49. data/sig/questdb-arrow.rbs +46 -0
  50. data/sig/questdb.rbs +337 -0
  51. metadata +188 -0
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ___ _ ____ ____
4
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
5
+ # | | | | | | |/ _ \/ __| __| | | | _ \
6
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
7
+ # \__\_\\__,_|\___||___/\__|____/|____/
8
+ #
9
+ # Copyright (c) 2014-2019 Appsicle
10
+ # Copyright (c) 2019-2026 QuestDB
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License");
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ #
24
+ require "thread"
25
+ require "weakref"
26
+
27
+ module QuestDB
28
+ class Client
29
+ EMPTY_HASH = {}.freeze
30
+ private_constant :EMPTY_HASH
31
+ DEFAULT_EVENT_INBOX_CAPACITY = 64
32
+ EventInbox = QuestDB.const_get(:EventInbox, false)
33
+ private_constant :EventInbox
34
+
35
+ def self.from_conf(conf, connection_listener: nil, error_handler: nil,
36
+ connection_event_inbox_capacity: DEFAULT_EVENT_INBOX_CAPACITY,
37
+ error_event_inbox_capacity: DEFAULT_EVENT_INBOX_CAPACITY, **settings)
38
+ configuration, effective = Config.build(conf, settings: settings)
39
+ new(configuration, effective_settings: effective,
40
+ connection_listener: connection_listener, error_handler: error_handler,
41
+ connection_event_inbox_capacity: connection_event_inbox_capacity,
42
+ error_event_inbox_capacity: error_event_inbox_capacity)
43
+ end
44
+
45
+ def self.from_env(**settings)
46
+ conf = ENV["QDB_CLIENT_CONF"]
47
+ raise Ingress::IngressError.new("QDB_CLIENT_CONF is not set", code: :config_error) unless conf && !conf.empty?
48
+ from_conf(conf, **settings)
49
+ end
50
+
51
+ def self.dispatcher_proc(owner, native, kind, handler, inbox)
52
+ method = kind == :connection ? :next_connection_event : :next_error_event
53
+ converter = kind == :connection ? ConnectionEvent : SenderError
54
+ proc do
55
+ Thread.current.name = "questdb-#{kind}-events" if Thread.current.respond_to?(:name=)
56
+ loop do
57
+ break unless owner.weakref_alive?
58
+ values = native.public_send(method, false)
59
+ unless values
60
+ break if native.closed?
61
+ sleep(0.01)
62
+ next
63
+ end
64
+ event = converter.from_native(values)
65
+ inbox.push(event)
66
+ begin
67
+ handler.call(event)
68
+ rescue Exception => error # callbacks are diagnostics and must not poison dispatch
69
+ Kernel.warn("QuestDB #{kind} callback failed: #{error.class}: #{error.message}")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ private_class_method :dispatcher_proc
75
+
76
+ def initialize(configuration, effective_settings: nil, connection_listener: nil, error_handler: nil,
77
+ connection_event_inbox_capacity: DEFAULT_EVENT_INBOX_CAPACITY,
78
+ error_event_inbox_capacity: DEFAULT_EVENT_INBOX_CAPACITY)
79
+ configuration = String(configuration).dup.freeze
80
+ validate_qwp!(configuration)
81
+ validate_handler!(connection_listener, "connection_listener")
82
+ validate_handler!(error_handler, "error_handler")
83
+ connection_capacity = validate_capacity!(connection_event_inbox_capacity, "connection_event_inbox_capacity")
84
+ error_capacity = validate_capacity!(error_event_inbox_capacity, "error_event_inbox_capacity")
85
+ parsed_settings = effective_settings || Config.parse(configuration)
86
+ @auto_flush = parse_auto_flush(parsed_settings)
87
+ @mutex = Mutex.new
88
+ @native = Ingress::Native::Client.new(
89
+ configuration, !connection_listener.nil?, connection_capacity,
90
+ !error_handler.nil?, error_capacity
91
+ )
92
+ @closed = false
93
+ @connection_inbox = connection_listener && EventInbox.new(connection_capacity)
94
+ @error_inbox = error_handler && EventInbox.new(error_capacity)
95
+ @dispatcher_threads = []
96
+ start_dispatcher(:connection, connection_listener, @connection_inbox) if connection_listener
97
+ start_dispatcher(:error, error_handler, @error_inbox) if error_handler
98
+ end
99
+
100
+ def sender
101
+ pooled = @mutex.synchronize do
102
+ ensure_open!
103
+ PooledSender.new(self, @native.borrow_sender, auto_flush: @auto_flush)
104
+ end
105
+ return pooled unless block_given?
106
+
107
+ begin
108
+ result = yield pooled
109
+ pooled.close
110
+ result
111
+ rescue Exception
112
+ pooled.close(flush: false)
113
+ raise
114
+ end
115
+ end
116
+
117
+ def reader
118
+ pooled = @mutex.synchronize do
119
+ ensure_open!
120
+ PooledReader.new(self, @native.borrow_reader)
121
+ end
122
+ return pooled unless block_given?
123
+
124
+ begin
125
+ yield pooled
126
+ ensure
127
+ pooled.close unless pooled.closed?
128
+ end
129
+ end
130
+
131
+ def query(sql, binds: nil, reset_symbol_dict: true)
132
+ pooled = reader
133
+ begin
134
+ result = pooled.query(sql, binds: binds, reset_symbol_dict: reset_symbol_dict)
135
+ result.send(:close_reader_on_close!)
136
+ rescue Exception
137
+ pooled.close unless pooled.closed?
138
+ raise
139
+ end
140
+ return result unless block_given?
141
+
142
+ begin
143
+ yield result
144
+ ensure
145
+ result.close
146
+ pooled.close unless pooled.closed?
147
+ end
148
+ end
149
+
150
+ # Write homogeneous Ruby columns over a pooled direct QWP connection.
151
+ # Unlike row ingestion this is a checked, non-store-and-forward commit:
152
+ # all batches are pipelined and committed at the requested ACK boundary.
153
+ def ingest_columns(table, columns:, symbols: EMPTY_HASH, at:, max_rows_per_batch: 16_384, ack: :ok)
154
+ prepared = DirectIngestion.prepare(
155
+ table,
156
+ columns: columns,
157
+ symbols: symbols,
158
+ at: at,
159
+ max_rows_per_batch: max_rows_per_batch,
160
+ ack: ack
161
+ )
162
+ @mutex.synchronize { ensure_open! }
163
+ return self if prepared[4].zero?
164
+
165
+ @native.ingest_columns(*prepared)
166
+ self
167
+ end
168
+ alias write_columns ingest_columns
169
+
170
+ def execute(sql, binds: nil)
171
+ reader { |pooled| pooled.execute(sql, binds: binds) }
172
+ nil
173
+ end
174
+
175
+ def server_info
176
+ reader { |pooled| return pooled.server_info }
177
+ end
178
+
179
+ def reap_idle
180
+ @mutex.synchronize do
181
+ ensure_open!
182
+ @native.reap_idle
183
+ end
184
+ end
185
+
186
+ # Nonblocking copies of values sent to the configured callback. These are
187
+ # independent of callback execution and use the same bounded/drop-oldest
188
+ # capacity policy.
189
+ def poll_connection_event = @connection_inbox&.poll
190
+ def poll_error = @error_inbox&.poll
191
+
192
+ def connection_events_delivered = counter(:connection_events_delivered, @connection_inbox)
193
+ def connection_events_dropped = counter(:connection_events_dropped, @connection_inbox, :dropped)
194
+ def error_events_delivered = counter(:error_events_delivered, @error_inbox)
195
+ def error_events_dropped = counter(:error_events_dropped, @error_inbox, :dropped)
196
+ def active_leases = (@closed ? 0 : @native.active_leases)
197
+ alias active_senders active_leases
198
+ def closed? = @closed || @native.closed?
199
+
200
+ def close
201
+ threads = nil
202
+ @mutex.synchronize do
203
+ return self if closed?
204
+ count = @native.active_leases
205
+ if count.positive?
206
+ raise Ingress::IngressError.new("cannot close client while #{count} pooled lease#{'s' unless count == 1} are active", code: :invalid_api_call)
207
+ end
208
+ @native.close
209
+ @closed = true
210
+ threads = @dispatcher_threads
211
+ @dispatcher_threads = []
212
+ end
213
+ current = Thread.current
214
+ threads.each { |thread| thread.join unless thread.equal?(current) }
215
+ self
216
+ end
217
+
218
+ def open
219
+ raise ArgumentError, "a block is required" unless block_given?
220
+ begin
221
+ yield self
222
+ ensure
223
+ close
224
+ end
225
+ end
226
+
227
+ def inspect = "#<#{self.class} closed=#{closed?}>"
228
+ def initialize_copy(*) = raise(TypeError, "QuestDB clients cannot be copied")
229
+
230
+ protected
231
+
232
+ def sender_released(_sender)
233
+ # Native lease accounting is authoritative and survives GC safely.
234
+ nil
235
+ end
236
+
237
+ private
238
+
239
+ def start_dispatcher(kind, handler, inbox)
240
+ thread = Thread.new(&self.class.send(:dispatcher_proc, WeakRef.new(self), @native, kind, handler, inbox))
241
+ thread.report_on_exception = false if thread.respond_to?(:report_on_exception=)
242
+ @dispatcher_threads << thread
243
+ end
244
+
245
+ def counter(method, inbox, ruby_counter = :delivered)
246
+ @mutex.synchronize do
247
+ ensure_open!
248
+ @native.public_send(method) + (inbox&.public_send(ruby_counter) || 0)
249
+ end
250
+ end
251
+
252
+ def ensure_open!
253
+ raise Ingress::IngressError.new("client is closed", code: :invalid_api_call) if closed?
254
+ end
255
+
256
+ def validate_handler!(handler, name)
257
+ return if handler.nil? || handler.respond_to?(:call)
258
+ raise TypeError, "#{name} must respond to #call"
259
+ end
260
+
261
+ def validate_capacity!(value, name)
262
+ capacity = Integer(value)
263
+ raise ArgumentError, "#{name} must be nonnegative" if capacity.negative?
264
+ capacity
265
+ rescue ArgumentError, TypeError
266
+ raise ArgumentError, "#{name} must be a nonnegative integer"
267
+ end
268
+
269
+ def validate_qwp!(conf)
270
+ return if conf.start_with?("ws::", "wss::")
271
+ raise Ingress::IngressError.new("QuestDB::Client accepts only ws:: or wss:: QWP configurations", code: :config_error)
272
+ end
273
+
274
+ def parse_auto_flush(settings)
275
+ enabled = switch(settings.fetch("auto_flush", "on"), "auto_flush")
276
+ rows = threshold(settings.fetch("auto_flush_rows", "1000"), "auto_flush_rows")
277
+ interval = threshold(settings.fetch("auto_flush_interval", "100"), "auto_flush_interval")
278
+ if settings.key?("auto_flush_bytes")
279
+ bytes = threshold(settings["auto_flush_bytes"], "auto_flush_bytes")
280
+ dynamic = false
281
+ else
282
+ bytes = nil
283
+ dynamic = true
284
+ end
285
+ unless enabled
286
+ rows = bytes = interval = nil
287
+ dynamic = false
288
+ end
289
+ if enabled && !dynamic && [rows, bytes, interval].none?
290
+ raise ArgumentError, "auto_flush cannot be enabled when every threshold is off"
291
+ end
292
+ { rows: rows, bytes: bytes, interval: interval, dynamic_bytes: dynamic }.freeze
293
+ end
294
+
295
+ def threshold(value, name)
296
+ return nil if %w[off false].include?(value.to_s)
297
+ number = Integer(value)
298
+ raise ArgumentError, "#{name} must be positive or off" unless number.positive?
299
+ number
300
+ end
301
+
302
+ def switch(value, name)
303
+ return true if %w[on true].include?(value.to_s)
304
+ return false if %w[off false].include?(value.to_s)
305
+ raise ArgumentError, "#{name} must be on or off"
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ___ _ ____ ____
4
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
5
+ # | | | | | | |/ _ \/ __| __| | | | _ \
6
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
7
+ # \__\_\\__,_|\___||___/\__|____/|____/
8
+ #
9
+ # Copyright (c) 2014-2019 Appsicle
10
+ # Copyright (c) 2019-2026 QuestDB
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License");
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ #
24
+
25
+ module QuestDB
26
+ # An explicit, immutable type descriptor for direct columnar ingestion.
27
+ # Use this when an Array is ambiguous (notably an all-nil column) or when a
28
+ # narrower QuestDB type than the conservative inferred type is required.
29
+ class ColumnVector
30
+ KINDS = %i[
31
+ byte short int long float double boolean varchar binary uuid long256 ipv4
32
+ timestamp timestamp_nanos date char geohash decimal64 decimal128 decimal256
33
+ double_array
34
+ ].freeze
35
+
36
+ attr_reader :kind, :values, :scale, :precision_bits, :shape
37
+
38
+ def initialize(kind, values, scale: nil, precision_bits: nil, shape: nil)
39
+ @kind = kind.to_sym
40
+ raise ArgumentError, "unsupported column kind #{@kind.inspect}" unless KINDS.include?(@kind)
41
+ raise TypeError, "values must be an Array" unless values.is_a?(Array)
42
+
43
+ @values = values.map { |value| immutable_copy(value) }.freeze
44
+ @scale = scale.nil? ? nil : Integer(scale)
45
+ @precision_bits = precision_bits.nil? ? nil : Integer(precision_bits)
46
+ @shape = shape&.map { |dimension| Integer(dimension) }&.freeze
47
+ validate_metadata!
48
+ freeze
49
+ end
50
+
51
+ KINDS.each do |kind|
52
+ define_singleton_method(kind) do |values, **metadata|
53
+ new(kind, values, **metadata)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def immutable_copy(value)
60
+ case value
61
+ when String then value.dup.freeze
62
+ when Array then value.map { |item| immutable_copy(item) }.freeze
63
+ else value
64
+ end
65
+ end
66
+
67
+ def validate_metadata!
68
+ if %i[decimal64 decimal128 decimal256].include?(kind)
69
+ raise ArgumentError, "#{kind} requires scale:" if scale.nil?
70
+ cap = { decimal64: 18, decimal128: 38, decimal256: 76 }.fetch(kind)
71
+ raise RangeError, "#{kind} scale must be between 0 and #{cap}" unless scale.between?(0, cap)
72
+ elsif !scale.nil?
73
+ raise ArgumentError, "scale: is only valid for decimal columns"
74
+ end
75
+
76
+ if kind == :geohash
77
+ raise ArgumentError, "geohash requires precision_bits:" if precision_bits.nil?
78
+ raise RangeError, "geohash precision must be between 1 and 60 bits" unless precision_bits.between?(1, 60)
79
+ elsif !precision_bits.nil?
80
+ raise ArgumentError, "precision_bits: is only valid for geohash columns"
81
+ end
82
+
83
+ if kind == :double_array
84
+ raise ArgumentError, "double_array requires shape:" unless shape
85
+ raise RangeError, "double_array shape must have 1..32 positive dimensions" unless shape.length.between?(1, 32) && shape.all?(&:positive?)
86
+ elsif shape
87
+ raise ArgumentError, "shape: is only valid for double_array columns"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ___ _ ____ ____
4
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
5
+ # | | | | | | |/ _ \/ __| __| | | | _ \
6
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
7
+ # \__\_\\__,_|\___||___/\__|____/|____/
8
+ #
9
+ # Copyright (c) 2014-2019 Appsicle
10
+ # Copyright (c) 2019-2026 QuestDB
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License");
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ #
24
+
25
+ module QuestDB
26
+ module Config
27
+ WRAPPER_KEYS = %w[auto_flush auto_flush_rows auto_flush_bytes auto_flush_interval].freeze
28
+ STANDALONE_WRAPPER_KEYS = (WRAPPER_KEYS + %w[init_buf_size]).freeze
29
+ module_function
30
+
31
+ def build(conf = nil, host: nil, port: nil, tls: nil, settings: {})
32
+ if (conf.nil?) == (host.nil?)
33
+ raise ArgumentError, "provide either a QWP configuration string or host:, but not both"
34
+ end
35
+
36
+ settings = settings.transform_keys(&:to_sym)
37
+ settings.each_key do |key|
38
+ raise ArgumentError, "invalid setting #{key.inspect}" unless key.to_s.match?(/\A[a-z_][a-z0-9_]*\z/)
39
+ end
40
+
41
+ if conf
42
+ raise ArgumentError, "port: and tls: require host:" unless port.nil? && tls.nil?
43
+ conf = String(conf)
44
+ schema, separator, rest = conf.partition("::")
45
+ raise Ingress::IngressError.new("configuration must start with ws:: or wss::", code: :config_error) unless separator == "::" && %w[ws wss].include?(schema)
46
+ parts = split_parts(rest)
47
+ keys = parts.filter_map do |part|
48
+ key, value = part.split("=", 2)
49
+ key if value && key.match?(/\A[a-z_][a-z0-9_]*\z/)
50
+ end
51
+ duplicate_in_conf = keys.tally.select { |_key, count| count > 1 }.keys
52
+ unless duplicate_in_conf.empty?
53
+ raise Ingress::IngressError.new("duplicate configuration keys: #{duplicate_in_conf.join(', ')}", code: :config_error)
54
+ end
55
+ values = parse(conf)
56
+ duplicates = settings.keys.select { |key| values.key?(key.to_s) }
57
+ raise ArgumentError, "settings already present in configuration: #{duplicates.join(', ')}" unless duplicates.empty?
58
+ effective = values.merge(settings.to_h { |key, value| [key.to_s, setting_value(value, key)] })
59
+ native_parts = parts.reject do |part|
60
+ key, value = part.split("=", 2)
61
+ value && WRAPPER_KEYS.include?(key)
62
+ end
63
+ keyword_parts = settings.reject { |key, _| WRAPPER_KEYS.include?(key.to_s) }
64
+ .map { |key, value| "#{key}=#{encode(value, key)};" }.join
65
+ rebuilt = native_parts.map do |part|
66
+ key, value = part.split("=", 2)
67
+ value ? "#{key}=#{encode(value)};" : "#{encode(part)};"
68
+ end.join
69
+ ["#{schema}::#{keyword_parts}#{rebuilt}", effective]
70
+ else
71
+ host = normalize_host(host)
72
+ port = port.nil? ? 9000 : Integer(port)
73
+ raise RangeError, "port must be between 1 and 65535" unless port.between?(1, 65_535)
74
+ raise TypeError, "tls must be true, false, or nil" unless tls.nil? || tls == true || tls == false
75
+ schema = tls ? "wss" : "ws"
76
+ endpoint = "#{host}:#{port}"
77
+ effective = { "addr" => endpoint }.merge(settings.to_h { |key, value| [key.to_s, setting_value(value, key)] })
78
+ native = effective.reject { |key, _| WRAPPER_KEYS.include?(key) }
79
+ built = "#{schema}::#{native.map { |key, value| "#{key}=#{encode(value, key.to_sym)};" }.join}"
80
+ [built, effective]
81
+ end
82
+ end
83
+
84
+ def parse(conf)
85
+ _schema, separator, body = String(conf).partition("::")
86
+ return {} unless separator == "::"
87
+ result = {}
88
+ split_parts(body).each do |part|
89
+ next if part.empty?
90
+ key, value = part.split("=", 2)
91
+ next unless value && key.match?(/\A[a-z_][a-z0-9_]*\z/)
92
+ result[key] = value
93
+ end
94
+ result
95
+ end
96
+
97
+ def split_parts(body)
98
+ parts = []
99
+ current = +""
100
+ index = 0
101
+ while index < body.length
102
+ if body.getbyte(index) == 59 # ;
103
+ if body.getbyte(index + 1) == 59
104
+ current << ";"
105
+ index += 2
106
+ else
107
+ parts << current
108
+ current = +""
109
+ index += 1
110
+ end
111
+ else
112
+ current << body[index]
113
+ index += 1
114
+ end
115
+ end
116
+ parts << current unless current.empty?
117
+ parts
118
+ end
119
+
120
+ def setting_value(value, key = nil)
121
+ if key&.to_sym == :tls_verify && value == false
122
+ "unsafe_off"
123
+ elsif value == true
124
+ "on"
125
+ elsif value == false
126
+ "off"
127
+ else
128
+ value.to_s
129
+ end
130
+ end
131
+
132
+ def encode(value, key = nil)
133
+ value = if key == :tls_verify && value == false
134
+ "unsafe_off"
135
+ elsif value == true
136
+ "on"
137
+ elsif value == false
138
+ "off"
139
+ else
140
+ value.to_s
141
+ end
142
+ raise ArgumentError, "configuration values cannot contain control characters" if value.match?(/[[:cntrl:]]/)
143
+ value.gsub(";", ";;")
144
+ end
145
+
146
+ def normalize_host(value)
147
+ host = String(value)
148
+ raise ArgumentError, "host must not be empty" if host.empty?
149
+ if host.start_with?("[")
150
+ raise ArgumentError, "invalid bracketed IPv6 host" unless host.end_with?("]")
151
+ host
152
+ elsif host.count(":") >= 2
153
+ "[#{host}]"
154
+ elsif host.include?(":")
155
+ raise ArgumentError, "host must not include a port; use port:"
156
+ else
157
+ host
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ___ _ ____ ____
4
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
5
+ # | | | | | | |/ _ \/ __| __| | | | _ \
6
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
7
+ # \__\_\\__,_|\___||___/\__|____/|____/
8
+ #
9
+ # Copyright (c) 2014-2019 Appsicle
10
+ # Copyright (c) 2019-2026 QuestDB
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License");
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ #
24
+
25
+ module QuestDB
26
+ # Immutable snapshot of one pool connection-lifecycle transition.
27
+ class ConnectionEvent
28
+ ATTRIBUTES = %i[
29
+ kind host port previous_host previous_port attempt_number cause_code
30
+ cause_message timestamp_millis
31
+ ].freeze
32
+ attr_reader(*ATTRIBUTES)
33
+
34
+ def initialize(kind:, host:, port:, previous_host:, previous_port:, attempt_number:,
35
+ cause_code:, cause_message:, timestamp_millis:)
36
+ @kind = kind.to_sym
37
+ @host = frozen_string(host)
38
+ @port = frozen_string(port)
39
+ @previous_host = frozen_string(previous_host)
40
+ @previous_port = frozen_string(previous_port)
41
+ @attempt_number = attempt_number.nil? ? nil : Integer(attempt_number)
42
+ @cause_code = cause_code&.to_sym
43
+ @cause_message = frozen_string(cause_message)
44
+ @timestamp_millis = Integer(timestamp_millis)
45
+ freeze
46
+ end
47
+
48
+ def self.from_native(values)
49
+ new(kind: values[0], host: values[1], port: values[2], previous_host: values[3],
50
+ previous_port: values[4], attempt_number: values[5], cause_code: values[6],
51
+ cause_message: values[7], timestamp_millis: values[8])
52
+ end
53
+
54
+ private
55
+
56
+ def frozen_string(value)
57
+ value.nil? ? nil : String(value).dup.freeze
58
+ end
59
+ end
60
+ end