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.
- 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.so +0 -0
- data/lib/questdb/native/3.2/questdb_ingress.so +0 -0
- data/lib/questdb/native/3.3/questdb_ingress.so +0 -0
- data/lib/questdb/native/3.4/questdb_ingress.so +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,429 @@
|
|
|
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
|
+
Column = Struct.new(:name, :kind, :metadata, keyword_init: true) do
|
|
27
|
+
def initialize(name:, kind:, metadata: {})
|
|
28
|
+
super(name: String(name).dup.freeze, kind: kind.to_sym, metadata: metadata.dup.freeze)
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Row
|
|
34
|
+
attr_reader :names, :values
|
|
35
|
+
def initialize(names, values, name_index = nil)
|
|
36
|
+
@names = names
|
|
37
|
+
@values = values.freeze
|
|
38
|
+
@name_index = name_index || begin
|
|
39
|
+
index = {}
|
|
40
|
+
names.each_with_index { |name, offset| index[name] ||= offset }
|
|
41
|
+
index.freeze
|
|
42
|
+
end
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
def [](key)
|
|
46
|
+
return values.fetch(key) if key.is_a?(Integer)
|
|
47
|
+
index = @name_index[row_key(key)]
|
|
48
|
+
index ? values[index] : nil
|
|
49
|
+
end
|
|
50
|
+
def fetch(key, *defaults, &block)
|
|
51
|
+
return values.fetch(key, *defaults, &block) if key.is_a?(Integer)
|
|
52
|
+
raise ArgumentError, "wrong number of default values" if defaults.length > 1
|
|
53
|
+
index = @name_index[row_key(key)]
|
|
54
|
+
return values[index] if index
|
|
55
|
+
return yield(key) if block
|
|
56
|
+
return defaults.first unless defaults.empty?
|
|
57
|
+
raise KeyError, "column not found: #{key.inspect}"
|
|
58
|
+
end
|
|
59
|
+
def to_a = values.dup
|
|
60
|
+
def to_h
|
|
61
|
+
names.each_with_index.each_with_object({}) { |(name, index), result| result[name] = values[index] }
|
|
62
|
+
end
|
|
63
|
+
def deconstruct = values
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def row_key(key) = key.is_a?(Symbol) ? key.name : key.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class ResultBatch
|
|
71
|
+
attr_reader :columns, :rows
|
|
72
|
+
def self.columns_from_native(native_columns)
|
|
73
|
+
native_columns.map do |name, kind, metadata|
|
|
74
|
+
Column.new(name: name, kind: kind, metadata: metadata || {})
|
|
75
|
+
end.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(native_columns, native_rows)
|
|
79
|
+
@columns = self.class.columns_from_native(native_columns)
|
|
80
|
+
names = @columns.map(&:name).freeze
|
|
81
|
+
name_index = {}
|
|
82
|
+
names.each_with_index { |name, index| name_index[name] ||= index }
|
|
83
|
+
name_index.freeze
|
|
84
|
+
@rows = native_rows.map { |values| Row.new(names, values, name_index) }.freeze
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
def row_count = rows.length
|
|
88
|
+
def column_count = columns.length
|
|
89
|
+
def empty? = rows.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class QueryResult
|
|
93
|
+
include Enumerable
|
|
94
|
+
attr_reader :columns
|
|
95
|
+
|
|
96
|
+
def initialize(reader, native)
|
|
97
|
+
@reader = reader
|
|
98
|
+
@native = native
|
|
99
|
+
@owner_thread = Thread.current
|
|
100
|
+
@columns = nil
|
|
101
|
+
@closed = false
|
|
102
|
+
@consumed = false
|
|
103
|
+
@cancelled = false
|
|
104
|
+
@close_reader = false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def each_batch
|
|
108
|
+
return enum_for(__method__) unless block_given?
|
|
109
|
+
each_native_batch do |native_columns, native_rows|
|
|
110
|
+
batch = ResultBatch.new(native_columns, native_rows)
|
|
111
|
+
@columns ||= batch.columns
|
|
112
|
+
yield batch
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def each
|
|
119
|
+
return enum_for(__method__) unless block_given?
|
|
120
|
+
each_batch { |batch| batch.rows.each { |row| yield row } }
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Lower-allocation streaming for callers that only need positional values.
|
|
125
|
+
# Arrays are owned copies and frozen before they are yielded; unlike Row,
|
|
126
|
+
# they do not support name-based lookup.
|
|
127
|
+
def each_values
|
|
128
|
+
return enum_for(__method__) unless block_given?
|
|
129
|
+
each_native_batch do |native_columns, native_rows|
|
|
130
|
+
@columns ||= ResultBatch.columns_from_native(native_columns)
|
|
131
|
+
native_rows.each do |values|
|
|
132
|
+
values.freeze
|
|
133
|
+
yield values
|
|
134
|
+
end
|
|
135
|
+
!native_rows.empty?
|
|
136
|
+
end
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whole-result materialization can safely authorize native replay: if a
|
|
141
|
+
# failover reset occurs, discard the partial prefix before accepting the
|
|
142
|
+
# replayed batch. Streaming iteration cannot retract yielded rows and raises
|
|
143
|
+
# instead (see #each_batch).
|
|
144
|
+
def to_a
|
|
145
|
+
start_consumption!
|
|
146
|
+
rows = []
|
|
147
|
+
reset_sequence = 0
|
|
148
|
+
begin
|
|
149
|
+
while (payload = @native.next_batch)
|
|
150
|
+
native_columns, native_rows, sequence = payload
|
|
151
|
+
if sequence != reset_sequence
|
|
152
|
+
rows.clear
|
|
153
|
+
@columns = nil
|
|
154
|
+
reset_sequence = sequence
|
|
155
|
+
end
|
|
156
|
+
batch = ResultBatch.new(native_columns, native_rows)
|
|
157
|
+
@columns ||= batch.columns
|
|
158
|
+
rows.concat(batch.rows)
|
|
159
|
+
end
|
|
160
|
+
ensure
|
|
161
|
+
close_preserving_primary_error
|
|
162
|
+
end
|
|
163
|
+
rows
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cancel
|
|
167
|
+
return self if @closed || @cancelled
|
|
168
|
+
ensure_thread!
|
|
169
|
+
begin
|
|
170
|
+
@native.cancel
|
|
171
|
+
rescue Exception => error
|
|
172
|
+
begin
|
|
173
|
+
close
|
|
174
|
+
rescue Exception
|
|
175
|
+
# Preserve the cancellation error; native close/drop is best-effort.
|
|
176
|
+
end
|
|
177
|
+
raise error
|
|
178
|
+
end
|
|
179
|
+
@cancelled = true
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def close
|
|
184
|
+
return self if @closed
|
|
185
|
+
ensure_thread!
|
|
186
|
+
reusable = @native.close
|
|
187
|
+
@closed = true
|
|
188
|
+
@reader.send(:result_released, self, reusable: reusable)
|
|
189
|
+
@reader.close if @close_reader && !@reader.closed?
|
|
190
|
+
self
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def closed? = @closed || @native.closed?
|
|
194
|
+
def consumed? = @consumed
|
|
195
|
+
|
|
196
|
+
def open
|
|
197
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
198
|
+
begin
|
|
199
|
+
yield self
|
|
200
|
+
ensure
|
|
201
|
+
close
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def initialize_copy(*) = raise(TypeError, "QuestDB query results cannot be copied")
|
|
206
|
+
|
|
207
|
+
protected
|
|
208
|
+
|
|
209
|
+
def close_reader_on_close!
|
|
210
|
+
@close_reader = true
|
|
211
|
+
self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def each_native_batch
|
|
217
|
+
start_consumption!
|
|
218
|
+
reset_sequence = 0
|
|
219
|
+
yielded = false
|
|
220
|
+
begin
|
|
221
|
+
while (payload = @native.next_batch)
|
|
222
|
+
native_columns, native_rows, sequence = payload
|
|
223
|
+
if yielded && sequence != reset_sequence
|
|
224
|
+
raise Ingress::IngressError.new(
|
|
225
|
+
"query failed over after rows were delivered; streaming replay would duplicate results",
|
|
226
|
+
code: :failover_would_duplicate,
|
|
227
|
+
in_doubt: true
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
reset_sequence = sequence
|
|
231
|
+
delivered = yield native_columns, native_rows
|
|
232
|
+
yielded ||= delivered
|
|
233
|
+
end
|
|
234
|
+
ensure
|
|
235
|
+
close_preserving_primary_error
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def start_consumption!
|
|
240
|
+
ensure_thread!
|
|
241
|
+
raise Ingress::IngressError.new("query result is single-use", code: :invalid_api_call) if @consumed
|
|
242
|
+
raise Ingress::IngressError.new("query result is closed", code: :invalid_api_call) if closed?
|
|
243
|
+
@consumed = true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def close_preserving_primary_error
|
|
247
|
+
primary = $!
|
|
248
|
+
begin
|
|
249
|
+
close
|
|
250
|
+
rescue Exception
|
|
251
|
+
raise unless primary
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def ensure_thread!
|
|
256
|
+
return if Thread.current.equal?(@owner_thread)
|
|
257
|
+
raise Ingress::IngressError.new("query result must be used on the thread that created it", code: :invalid_api_call)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
class PooledReader
|
|
262
|
+
def initialize(client, native)
|
|
263
|
+
@client = client
|
|
264
|
+
@native = native
|
|
265
|
+
@owner_thread = Thread.current
|
|
266
|
+
@active_result = nil
|
|
267
|
+
@closed = false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def query(sql, binds: nil, reset_symbol_dict: true)
|
|
271
|
+
ensure_open!
|
|
272
|
+
raise Ingress::IngressError.new("reader already has an active result", code: :invalid_api_call) if @active_result && !@active_result.closed?
|
|
273
|
+
descriptors = Bindings.normalize(binds)
|
|
274
|
+
result = QueryResult.new(self, @native.query(String(sql), descriptors, !!reset_symbol_dict))
|
|
275
|
+
@active_result = result
|
|
276
|
+
return result unless block_given?
|
|
277
|
+
begin
|
|
278
|
+
yield result
|
|
279
|
+
ensure
|
|
280
|
+
result.close
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def execute(sql, binds: nil)
|
|
285
|
+
query(sql, binds: binds) { |result| result.each_batch { |_batch| } }
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def server_info
|
|
290
|
+
ensure_open!
|
|
291
|
+
raise Ingress::IngressError.new("reader has an active result", code: :invalid_api_call) if @active_result && !@active_result.closed?
|
|
292
|
+
ServerInfo.from_native(@native.server_info)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def close
|
|
296
|
+
return self if @closed
|
|
297
|
+
ensure_thread!
|
|
298
|
+
@active_result.close if @active_result && !@active_result.closed?
|
|
299
|
+
return self if @closed
|
|
300
|
+
@native.close(false)
|
|
301
|
+
@closed = true
|
|
302
|
+
self
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def closed? = @closed || @native.closed?
|
|
306
|
+
|
|
307
|
+
def open
|
|
308
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
309
|
+
begin
|
|
310
|
+
yield self
|
|
311
|
+
ensure
|
|
312
|
+
close
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def initialize_copy(*) = raise(TypeError, "QuestDB pooled readers cannot be copied")
|
|
317
|
+
|
|
318
|
+
protected
|
|
319
|
+
|
|
320
|
+
def result_released(result, reusable: true)
|
|
321
|
+
@active_result = nil if @active_result.equal?(result)
|
|
322
|
+
unless reusable || closed?
|
|
323
|
+
@native.close(true)
|
|
324
|
+
@closed = true
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def ensure_thread!
|
|
331
|
+
return if Thread.current.equal?(@owner_thread)
|
|
332
|
+
raise Ingress::IngressError.new("pooled reader must be used on the thread that borrowed it", code: :invalid_api_call)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ensure_open!
|
|
336
|
+
ensure_thread!
|
|
337
|
+
raise Ingress::IngressError.new("pooled reader is closed", code: :invalid_api_call) if closed?
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
ServerInfo = Struct.new(:role, :raw_role, :epoch, :capabilities, :server_wall_ns, :cluster_id, :node_id, :zone_id, keyword_init: true) do
|
|
342
|
+
ROLES = %i[standalone primary replica primary_catchup other].freeze
|
|
343
|
+
def self.from_native(values)
|
|
344
|
+
new(role: values[0], raw_role: values[1], epoch: values[2], capabilities: values[3],
|
|
345
|
+
server_wall_ns: values[4], cluster_id: values[5], node_id: values[6], zone_id: values[7])
|
|
346
|
+
end
|
|
347
|
+
def initialize(role:, raw_role:, epoch:, capabilities:, server_wall_ns:, cluster_id:, node_id:, zone_id:)
|
|
348
|
+
super(role: role.to_sym, raw_role: Integer(raw_role), epoch: Integer(epoch), capabilities: Integer(capabilities),
|
|
349
|
+
server_wall_ns: Integer(server_wall_ns), cluster_id: String(cluster_id).dup.freeze,
|
|
350
|
+
node_id: String(node_id).dup.freeze, zone_id: zone_id&.then { |v| String(v).dup.freeze })
|
|
351
|
+
freeze
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
module Bindings
|
|
356
|
+
KIND_CODES = {
|
|
357
|
+
boolean: 0x01, byte: 0x02, short: 0x03, int: 0x04, long: 0x05,
|
|
358
|
+
float: 0x06, double: 0x07, symbol: 0x09, timestamp: 0x0a,
|
|
359
|
+
date: 0x0b, uuid: 0x0c, long256: 0x0d, geohash: 0x0e,
|
|
360
|
+
varchar: 0x0f, timestamp_nanos: 0x10, decimal64: 0x13,
|
|
361
|
+
decimal128: 0x14, decimal256: 0x15, char: 0x16, binary: 0x17, ipv4: 0x18
|
|
362
|
+
}.freeze
|
|
363
|
+
module_function
|
|
364
|
+
|
|
365
|
+
def normalize(binds)
|
|
366
|
+
return [] if binds.nil?
|
|
367
|
+
raise TypeError, "binds must be an Array" unless binds.is_a?(Array)
|
|
368
|
+
binds.each_with_index.map { |value, index| normalize_one(value, index + 1) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def normalize_one(value, position)
|
|
372
|
+
case value
|
|
373
|
+
when nil then [:null, KIND_CODES[:varchar], nil, nil]
|
|
374
|
+
when true, false then [:bool, value]
|
|
375
|
+
when Integer
|
|
376
|
+
range_i64(value, position)
|
|
377
|
+
[:i64, value]
|
|
378
|
+
when Float then [:f64, value]
|
|
379
|
+
when String then [:varchar, utf8(value)]
|
|
380
|
+
when Time then [:ts_micros, TimestampMicros.from_time(value).value]
|
|
381
|
+
when TimestampNanos then [:ts_nanos, value.value]
|
|
382
|
+
when TimestampMicros then [:ts_micros, value.value]
|
|
383
|
+
when DateMillis then [:date, value.value]
|
|
384
|
+
when Char then [:char, value.value]
|
|
385
|
+
when UUID then [:uuid, value.bytes]
|
|
386
|
+
when Binary then [:binary, value.bytes]
|
|
387
|
+
when IPv4 then [:ipv4, value.value]
|
|
388
|
+
when IPAddr then [:ipv4, IPv4.new(value).value]
|
|
389
|
+
when Geohash then [:geohash, value.value, value.precision_bits]
|
|
390
|
+
when Long256 then [:long256, value.bytes]
|
|
391
|
+
when DecimalValue then decimal_descriptor(value)
|
|
392
|
+
when BigDecimal then decimal_descriptor(DecimalValue.from_bigdecimal(value))
|
|
393
|
+
when TypedNull
|
|
394
|
+
code = KIND_CODES.fetch(value.kind)
|
|
395
|
+
[:null, code, value.scale, value.precision_bits]
|
|
396
|
+
else
|
|
397
|
+
raise TypeError, "unsupported bind $#{position}: #{value.class}"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def decimal_descriptor(value)
|
|
402
|
+
mantissa = value.mantissa
|
|
403
|
+
scale = value.scale
|
|
404
|
+
if mantissa.between?(-(1 << 63), (1 << 63) - 1)
|
|
405
|
+
[:decimal64, mantissa, scale]
|
|
406
|
+
elsif mantissa.between?(-(1 << 127), (1 << 127) - 1)
|
|
407
|
+
unsigned = mantissa % (1 << 128)
|
|
408
|
+
high = unsigned >> 64
|
|
409
|
+
high -= 1 << 64 if high >= 1 << 63
|
|
410
|
+
[:decimal128, unsigned & 0xffff_ffff_ffff_ffff, high, scale]
|
|
411
|
+
elsif mantissa.between?(-(1 << 255), (1 << 255) - 1)
|
|
412
|
+
unsigned = mantissa % (1 << 256)
|
|
413
|
+
[:decimal256, [unsigned.to_s(16).rjust(64, "0")].pack("H*").reverse, scale]
|
|
414
|
+
else
|
|
415
|
+
raise RangeError, "decimal mantissa exceeds signed 256-bit range"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def range_i64(value, position)
|
|
420
|
+
raise RangeError, "bind $#{position} is outside signed 64-bit range" unless value.between?(-(1 << 63), (1 << 63) - 1)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def utf8(value)
|
|
424
|
+
value.encode(Encoding::UTF_8)
|
|
425
|
+
rescue EncodingError => error
|
|
426
|
+
raise Ingress::IngressError.new(error.message, code: :invalid_utf8), cause: error
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|