wide_events 0.1.4 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +149 -8
- data/lib/generators/wide_events/install/install_generator.rb +16 -2
- data/lib/generators/wide_events/skill_installer.rb +24 -0
- data/lib/generators/wide_events/skills/skills_generator.rb +9 -7
- data/lib/generators/wide_events/store/store_generator.rb +145 -0
- data/lib/generators/wide_events/store/templates/accessory.yml.erb +33 -0
- data/lib/wide_event/configuration.rb +64 -3
- data/lib/wide_event/job_instrumentation.rb +2 -0
- data/lib/wide_event/kamal/deploy_editor.rb +499 -0
- data/lib/wide_event/kamal/secrets_editor.rb +103 -0
- data/lib/wide_event/middleware.rb +2 -0
- data/lib/wide_event/railtie.rb +8 -0
- data/lib/wide_event/registry/defaults.yml +5 -0
- data/lib/wide_event/setup/checker.rb +402 -0
- data/lib/wide_event/setup/command_runner.rb +25 -0
- data/lib/wide_event/sinks/store.rb +33 -0
- data/lib/wide_event/store/client.rb +258 -0
- data/lib/wide_event/store/envelope.rb +60 -0
- data/lib/wide_event/store/formatter.rb +60 -0
- data/lib/wide_event/store/query_result.rb +19 -0
- data/lib/wide_event/store/sender.rb +448 -0
- data/lib/wide_event/tasks/setup.rake +55 -0
- data/lib/wide_event/tasks/store.rake +122 -0
- data/lib/wide_event/version.rb +1 -1
- data/lib/wide_event.rb +21 -0
- data/skills/debugging-with-wide-events/SKILL.md +92 -7
- data/skills/setting-up-wide-events-store/SKILL.md +120 -0
- metadata +36 -6
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module WideEvent
|
|
7
|
+
module Store
|
|
8
|
+
# Minimal standard-library HTTPS client for POST /v1/events
|
|
9
|
+
# (store/internal/httpapi/server.go) and POST /v1/query
|
|
10
|
+
# (store/internal/httpapi/query.go). One instance issues one request at
|
|
11
|
+
# a time. #ingest is called only from WideEvent::Store::Sender's worker
|
|
12
|
+
# thread; #query is called from the wide_events:sql Rails task on
|
|
13
|
+
# whatever thread invokes it (there is no persistent worker for
|
|
14
|
+
# queries).
|
|
15
|
+
class Client
|
|
16
|
+
# Tags a failure as retryable (timeout/network, 429, 507, 5xx) or
|
|
17
|
+
# permanent (other 4xx). Never carries the ingest token or an event
|
|
18
|
+
# body in its message.
|
|
19
|
+
class Error < StandardError
|
|
20
|
+
def initialize(message, retryable:)
|
|
21
|
+
super(message)
|
|
22
|
+
@retryable = retryable
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retryable?
|
|
26
|
+
@retryable
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Ack = Struct.new(:protocol_version, :batch_id, :duplicate, :warnings, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
OPEN_TIMEOUT = 1
|
|
33
|
+
READ_TIMEOUT = 2
|
|
34
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
35
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
36
|
+
INGEST_PATH = "/v1/events"
|
|
37
|
+
QUERY_PATH = "/v1/query"
|
|
38
|
+
QUERY_PROTOCOL_VERSION = 1
|
|
39
|
+
|
|
40
|
+
# The store caps its own query responses at 10 MiB
|
|
41
|
+
# (store/internal/query/executor.go's defaultMaxResponseBytes); we read
|
|
42
|
+
# one byte past that so a response that actually reaches the cap+1
|
|
43
|
+
# byte is distinguishable from one that legitimately ends at (or
|
|
44
|
+
# under) 10 MiB, instead of silently parsing a truncated body.
|
|
45
|
+
QUERY_RESPONSE_CAP = 10 * 1024 * 1024
|
|
46
|
+
DEFAULT_QUERY_DEADLINE = 30
|
|
47
|
+
QUERY_FORMATS = %w[json csv].freeze
|
|
48
|
+
RETRY_AFTER_DEFAULT = 1
|
|
49
|
+
RETRY_AFTER_CAP = 10
|
|
50
|
+
|
|
51
|
+
NETWORK_ERRORS = [
|
|
52
|
+
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
|
|
53
|
+
Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE, SocketError,
|
|
54
|
+
OpenSSL::SSL::SSLError, EOFError, IOError
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Token presence is validated per-operation (in #ingest and #query),
|
|
58
|
+
# not here: a Client built only to query has no ingest token to give
|
|
59
|
+
# it, and vice versa. The constructor still validates the URL, since
|
|
60
|
+
# that's shared by both operations and needed before either can run.
|
|
61
|
+
def initialize(url:, ingest_token: nil, query_token: nil, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT)
|
|
62
|
+
@uri = URI.parse(url.to_s)
|
|
63
|
+
raise ArgumentError, "store url must be an absolute http(s) URL" unless @uri.host
|
|
64
|
+
|
|
65
|
+
unless https?(@uri) || loopback?(@uri)
|
|
66
|
+
raise ArgumentError, "store url must use HTTPS (plain http is only allowed for loopback testing)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@ingest_token = ingest_token.to_s
|
|
70
|
+
@query_token = query_token.to_s
|
|
71
|
+
@open_timeout = open_timeout
|
|
72
|
+
@read_timeout = read_timeout
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Sends one already-gzipped canonical batch body. Returns an Ack on a
|
|
76
|
+
# 200 response; raises Error (tagged retryable?) otherwise.
|
|
77
|
+
def ingest(gzip_body)
|
|
78
|
+
raise ArgumentError, "store ingest token is required" if @ingest_token.empty?
|
|
79
|
+
code, body = perform(gzip_body)
|
|
80
|
+
handle_response(code, body)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Runs one read-only statement. Retries a busy (429) response, bounded
|
|
84
|
+
# by the server's Retry-After header, until `deadline` monotonic
|
|
85
|
+
# seconds have elapsed overall; policy/limit errors (400/422/503) are
|
|
86
|
+
# never retried here. Returns a QueryResult for format: :json, or the
|
|
87
|
+
# server's CSV text verbatim for format: :csv.
|
|
88
|
+
def query(sql, format: :json, deadline: DEFAULT_QUERY_DEADLINE)
|
|
89
|
+
raise ArgumentError, "store query token is required" if @query_token.empty?
|
|
90
|
+
format = format.to_s
|
|
91
|
+
raise ArgumentError, "query format must be :json or :csv" unless QUERY_FORMATS.include?(format)
|
|
92
|
+
|
|
93
|
+
deadline_at = monotonic_now + deadline
|
|
94
|
+
loop do
|
|
95
|
+
code, body, retry_after = perform_query(sql, format)
|
|
96
|
+
if code == 429
|
|
97
|
+
sleep(bounded_retry_wait(retry_after, deadline_at))
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
return handle_query_response(code, body, format)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Ruby's Net::HTTP unconditionally finishes reading the response body
|
|
107
|
+
# itself (via HTTPResponse#reading_body's trailing `self.body` call)
|
|
108
|
+
# once the block passed to #request returns *normally* — a plain
|
|
109
|
+
# `break` inside #read_body's block only stops OUR loop; Net::HTTP
|
|
110
|
+
# then tries to read the REST of the body anyway, which would hang
|
|
111
|
+
# forever against a server that never stops sending. Raising instead
|
|
112
|
+
# unwinds past that trailing read entirely; we catch it below.
|
|
113
|
+
BoundedBodyExceeded = Class.new(StandardError) do
|
|
114
|
+
attr_reader :buffer
|
|
115
|
+
|
|
116
|
+
def initialize(buffer, max_bytes)
|
|
117
|
+
@buffer = buffer
|
|
118
|
+
super("response body exceeded #{max_bytes} bytes")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def perform(gzip_body)
|
|
123
|
+
request = Net::HTTP::Post.new(INGEST_PATH)
|
|
124
|
+
request["Authorization"] = "Bearer #{@ingest_token}"
|
|
125
|
+
request["Content-Type"] = "application/json"
|
|
126
|
+
request["Content-Encoding"] = "gzip"
|
|
127
|
+
request.body = gzip_body
|
|
128
|
+
|
|
129
|
+
code, body, = perform_request(request, MAX_RESPONSE_BYTES)
|
|
130
|
+
[ code, body ]
|
|
131
|
+
rescue *NETWORK_ERRORS => e
|
|
132
|
+
raise Error.new("store request failed (#{e.class})", retryable: true)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def perform_query(sql, format)
|
|
136
|
+
request = Net::HTTP::Post.new(QUERY_PATH)
|
|
137
|
+
request["Authorization"] = "Bearer #{@query_token}"
|
|
138
|
+
request["Content-Type"] = "application/json"
|
|
139
|
+
request.body = JSON.generate(protocol_version: QUERY_PROTOCOL_VERSION, sql: sql, format: format)
|
|
140
|
+
|
|
141
|
+
code, body, response = perform_request(request, QUERY_RESPONSE_CAP + 1)
|
|
142
|
+
[ code, body, response && response["Retry-After"] ]
|
|
143
|
+
rescue *NETWORK_ERRORS => e
|
|
144
|
+
raise Error.new("store query request failed (#{e.class})", retryable: true)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Shared connect/send/bounded-read path for both endpoints. Returns
|
|
148
|
+
# the response object too (query needs its Retry-After header; ingest
|
|
149
|
+
# ignores it) — the caller must not read the body of that object
|
|
150
|
+
# again, since #read_body has already been drained into `body`.
|
|
151
|
+
def perform_request(request, max_bytes)
|
|
152
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
153
|
+
http.use_ssl = https?(@uri)
|
|
154
|
+
http.open_timeout = @open_timeout
|
|
155
|
+
http.read_timeout = @read_timeout
|
|
156
|
+
|
|
157
|
+
code = nil
|
|
158
|
+
body = nil
|
|
159
|
+
captured_response = nil
|
|
160
|
+
begin
|
|
161
|
+
http.start do |connection|
|
|
162
|
+
connection.request(request) do |response|
|
|
163
|
+
code = response.code.to_i
|
|
164
|
+
captured_response = response
|
|
165
|
+
body = read_bounded(response, max_bytes)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
rescue BoundedBodyExceeded => e
|
|
169
|
+
body = e.buffer
|
|
170
|
+
end
|
|
171
|
+
[ code, body, captured_response ]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def read_bounded(response, max_bytes)
|
|
175
|
+
buffer = "".b
|
|
176
|
+
response.read_body do |chunk|
|
|
177
|
+
remaining = max_bytes - buffer.bytesize
|
|
178
|
+
buffer << (chunk.bytesize > remaining ? chunk.byteslice(0, remaining) : chunk)
|
|
179
|
+
raise BoundedBodyExceeded.new(buffer, max_bytes) if buffer.bytesize >= max_bytes
|
|
180
|
+
end
|
|
181
|
+
buffer
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def handle_response(code, body)
|
|
185
|
+
case code
|
|
186
|
+
when 200 then parse_ack(body)
|
|
187
|
+
when 429, 507, 500..599 then raise Error.new("store responded with #{code}", retryable: true)
|
|
188
|
+
else raise Error.new("store responded with #{code}", retryable: false)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def handle_query_response(code, body, format)
|
|
193
|
+
case code
|
|
194
|
+
when 200
|
|
195
|
+
format == "csv" ? validate_csv_size(body) : parse_query_result(body)
|
|
196
|
+
when 400 then raise Error.new("query was rejected (invalid SQL, or not read-only)", retryable: false)
|
|
197
|
+
when 422 then raise Error.new("query exceeded a store limit (rows, time, or response size)", retryable: false)
|
|
198
|
+
when 503 then raise Error.new("store is temporarily unavailable (maintenance)", retryable: true)
|
|
199
|
+
else raise Error.new("store responded with #{code}", retryable: false)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_csv_size(body)
|
|
204
|
+
raise Error.new("store query response exceeded the #{QUERY_RESPONSE_CAP} byte cap", retryable: false) if body.bytesize > QUERY_RESPONSE_CAP
|
|
205
|
+
body
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse_query_result(body)
|
|
209
|
+
raise Error.new("store query response exceeded the #{QUERY_RESPONSE_CAP} byte cap", retryable: false) if body.bytesize > QUERY_RESPONSE_CAP
|
|
210
|
+
json = JSON.parse(body)
|
|
211
|
+
QueryResult.new(
|
|
212
|
+
columns: Array(json["columns"]),
|
|
213
|
+
rows: Array(json["rows"]).map { |row| Array(row) },
|
|
214
|
+
truncated: json["truncated"] == true
|
|
215
|
+
)
|
|
216
|
+
rescue JSON::ParserError, TypeError
|
|
217
|
+
raise Error.new("store returned an unparsable query result", retryable: false)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# `retry_after` is the raw (possibly nil/blank/malformed) header
|
|
221
|
+
# value; a missing or non-numeric value falls back to
|
|
222
|
+
# RETRY_AFTER_DEFAULT rather than trusting an unbounded string.
|
|
223
|
+
# Whatever it resolves to is clamped to RETRY_AFTER_CAP and to
|
|
224
|
+
# whatever time remains before `deadline_at`.
|
|
225
|
+
def bounded_retry_wait(retry_after, deadline_at)
|
|
226
|
+
remaining = deadline_at - monotonic_now
|
|
227
|
+
raise Error.new("store is busy and the query deadline elapsed", retryable: true) if remaining <= 0
|
|
228
|
+
seconds = Integer(retry_after) rescue nil
|
|
229
|
+
seconds = RETRY_AFTER_DEFAULT if seconds.nil? || seconds.negative?
|
|
230
|
+
[ seconds, RETRY_AFTER_CAP, remaining ].min
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def monotonic_now
|
|
234
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def parse_ack(body)
|
|
238
|
+
json = JSON.parse(body)
|
|
239
|
+
Ack.new(
|
|
240
|
+
protocol_version: json["protocol_version"],
|
|
241
|
+
batch_id: json["batch_id"],
|
|
242
|
+
duplicate: json["duplicate"] == true,
|
|
243
|
+
warnings: Array(json["warnings"])
|
|
244
|
+
)
|
|
245
|
+
rescue JSON::ParserError, TypeError
|
|
246
|
+
raise Error.new("store returned an unparsable acknowledgement", retryable: true)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def https?(uri)
|
|
250
|
+
uri.scheme == "https"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def loopback?(uri)
|
|
254
|
+
uri.scheme == "http" && LOOPBACK_HOSTS.include?(uri.host)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module WideEvent
|
|
5
|
+
module Store
|
|
6
|
+
# Renders one flushed wide-event hash as the canonical event JSON the
|
|
7
|
+
# DuckDB store's protocol decoder expects (store/internal/protocol/events.go):
|
|
8
|
+
# an `id`, UTC RFC3339-with-milliseconds `occurred_at`, `kind`
|
|
9
|
+
# ("request"/"job"), required `duration_ms`, optional `deployment`/
|
|
10
|
+
# `source`, and an `attributes` map of sanitized primitives.
|
|
11
|
+
#
|
|
12
|
+
# Pure and synchronous: no I/O, no mutation of the input, and no
|
|
13
|
+
# WideEvent.handle_error rescue of its own — callers that send events
|
|
14
|
+
# over the network (Task 8) rescue StandardError around #build and route
|
|
15
|
+
# it through WideEvent.handle_error themselves.
|
|
16
|
+
module Envelope
|
|
17
|
+
MAX_ATTRIBUTES = 256
|
|
18
|
+
MAX_EVENT_BYTES = 64 * 1024
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def build(attrs, clock:, uuid:, env:)
|
|
22
|
+
attributes = WideEvent.sanitize(attrs)
|
|
23
|
+
duration_ms = attributes.delete("duration_ms")
|
|
24
|
+
raise ArgumentError, "duration_ms is required" unless duration_ms.is_a?(Numeric)
|
|
25
|
+
|
|
26
|
+
if attributes.size > MAX_ATTRIBUTES
|
|
27
|
+
raise ArgumentError, "event has #{attributes.size} attributes, max #{MAX_ATTRIBUTES}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
event = {
|
|
31
|
+
"id" => uuid.call,
|
|
32
|
+
"occurred_at" => clock.call.utc.iso8601(3),
|
|
33
|
+
"kind" => attributes["job.class"] ? "job" : "request",
|
|
34
|
+
"duration_ms" => duration_ms,
|
|
35
|
+
"deployment" => deployment(env),
|
|
36
|
+
"source" => source(env),
|
|
37
|
+
"attributes" => attributes
|
|
38
|
+
}.compact
|
|
39
|
+
|
|
40
|
+
json = JSON.generate(event)
|
|
41
|
+
if json.bytesize > MAX_EVENT_BYTES
|
|
42
|
+
raise ArgumentError, "event is #{json.bytesize} bytes, max #{MAX_EVENT_BYTES}"
|
|
43
|
+
end
|
|
44
|
+
json.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def deployment(env)
|
|
50
|
+
value = env["KAMAL_VERSION"]
|
|
51
|
+
value if value.present?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source(env)
|
|
55
|
+
[ env["KAMAL_CONTAINER_NAME"], env["HOSTNAME"] ].find(&:present?) || Socket.gethostname
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "csv"
|
|
3
|
+
|
|
4
|
+
module WideEvent
|
|
5
|
+
module Store
|
|
6
|
+
# Renders a bounded QueryResult for a terminal (table), for machine
|
|
7
|
+
# consumption (json), or for a spreadsheet/pipe (csv). Every format
|
|
8
|
+
# reads the whole (already-capped) result once; none of them stream or
|
|
9
|
+
# re-request data, and none of them ever emit ANSI escape sequences —
|
|
10
|
+
# the table formatter is plain text whether or not `io` is a TTY.
|
|
11
|
+
module Formatter
|
|
12
|
+
NULL_TEXT = "NULL"
|
|
13
|
+
|
|
14
|
+
def self.table(result, io:)
|
|
15
|
+
header = result.columns
|
|
16
|
+
body_rows = result.rows.map { |row| row.map { |value| value.nil? ? NULL_TEXT : value.to_s } }
|
|
17
|
+
widths = column_widths(header, body_rows)
|
|
18
|
+
|
|
19
|
+
io.puts(row_line(header, widths))
|
|
20
|
+
io.puts(separator_line(widths))
|
|
21
|
+
body_rows.each { |row| io.puts(row_line(row, widths)) }
|
|
22
|
+
io.puts("(truncated: the store capped this result)") if result.truncated
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.json(result, io:)
|
|
26
|
+
io.puts(JSON.generate(columns: result.columns, rows: result.rows, truncated: result.truncated))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.csv(result, io:)
|
|
30
|
+
io.print(CSV.generate_line(result.columns))
|
|
31
|
+
result.rows.each do |row|
|
|
32
|
+
# Pass `nil` through as-is (CSV renders it as an empty, unquoted
|
|
33
|
+
# field) rather than the string "" (which CSV quotes as `""` to
|
|
34
|
+
# keep it distinct from a genuinely absent field) — matching the
|
|
35
|
+
# store's own CSV null rendering (query/executor.go's csvValue).
|
|
36
|
+
io.print(CSV.generate_line(row.map { |value| value.nil? ? nil : value.to_s }))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.column_widths(header, body_rows)
|
|
41
|
+
widths = header.map(&:length)
|
|
42
|
+
body_rows.each do |row|
|
|
43
|
+
row.each_with_index { |value, index| widths[index] = [ widths[index], value.length ].max }
|
|
44
|
+
end
|
|
45
|
+
widths
|
|
46
|
+
end
|
|
47
|
+
private_class_method :column_widths
|
|
48
|
+
|
|
49
|
+
def self.row_line(values, widths)
|
|
50
|
+
"| " + values.each_with_index.map { |value, index| value.ljust(widths[index]) }.join(" | ") + " |"
|
|
51
|
+
end
|
|
52
|
+
private_class_method :row_line
|
|
53
|
+
|
|
54
|
+
def self.separator_line(widths)
|
|
55
|
+
"+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+"
|
|
56
|
+
end
|
|
57
|
+
private_class_method :separator_line
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module WideEvent
|
|
2
|
+
module Store
|
|
3
|
+
# An immutable, already-bounded result of a JSON /v1/query request
|
|
4
|
+
# (store/internal/httpapi/query.go's `{"columns":...,"rows":...,"truncated":...}`
|
|
5
|
+
# body, parsed). `rows` is an array of arrays in column order; a `nil`
|
|
6
|
+
# element is a SQL NULL. `truncated` is true when the store cut the
|
|
7
|
+
# result off at its row or response-size limit.
|
|
8
|
+
class QueryResult
|
|
9
|
+
attr_reader :columns, :rows, :truncated
|
|
10
|
+
|
|
11
|
+
def initialize(columns:, rows:, truncated:)
|
|
12
|
+
@columns = columns.map(&:to_s).freeze
|
|
13
|
+
@rows = rows.map { |row| row.freeze }.freeze
|
|
14
|
+
@truncated = truncated == true
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|