questdb 0.1.0-arm64-darwin
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 +22 -0
- data/LICENSE.txt +201 -0
- data/README.md +170 -0
- data/SECURITY.md +19 -0
- data/THIRD_PARTY_NOTICES.md +242 -0
- data/docs/arrow.md +89 -0
- data/docs/configuration.md +61 -0
- data/docs/design.md +46 -0
- data/docs/ingestion.md +96 -0
- data/docs/performance.md +36 -0
- data/docs/python-parity.md +34 -0
- data/docs/querying.md +54 -0
- data/docs/release-infrastructure.md +70 -0
- data/docs/releasing.md +47 -0
- data/docs/troubleshooting.md +41 -0
- data/examples/basic.rb +32 -0
- data/examples/callbacks.rb +46 -0
- data/examples/columnar.rb +41 -0
- data/examples/independent_buffer.rb +39 -0
- data/examples/query.rb +28 -0
- data/examples/standalone.rb +33 -0
- data/examples/tls_auth.rb +33 -0
- data/lib/questdb/arrow.rb +250 -0
- data/lib/questdb/client.rb +308 -0
- data/lib/questdb/column_vector.rb +91 -0
- data/lib/questdb/config.rb +161 -0
- data/lib/questdb/connection_event.rb +60 -0
- data/lib/questdb/direct_ingestion.rb +440 -0
- data/lib/questdb/event_inbox.rb +60 -0
- data/lib/questdb/ingress/buffer.rb +104 -0
- data/lib/questdb/ingress/error.rb +43 -0
- data/lib/questdb/ingress/float64_array.rb +60 -0
- data/lib/questdb/ingress/native.rb +40 -0
- data/lib/questdb/ingress/timestamp.rb +53 -0
- data/lib/questdb/ingress.rb +77 -0
- data/lib/questdb/native/3.1/questdb_ingress.bundle +0 -0
- data/lib/questdb/native/3.2/questdb_ingress.bundle +0 -0
- data/lib/questdb/native/3.3/questdb_ingress.bundle +0 -0
- data/lib/questdb/native/3.4/questdb_ingress.bundle +0 -0
- data/lib/questdb/pooled_sender.rb +215 -0
- data/lib/questdb/query.rb +429 -0
- data/lib/questdb/sender.rb +436 -0
- data/lib/questdb/sender_error.rb +49 -0
- data/lib/questdb/types.rb +198 -0
- data/lib/questdb/version.rb +27 -0
- data/lib/questdb.rb +22 -0
- data/questdb.gemspec +69 -0
- data/sig/questdb-arrow.rbs +46 -0
- data/sig/questdb.rbs +337 -0
- 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
|