rubydb 0.1.5 → 0.1.6

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/CHANGELOG.md +14 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +296 -227
  6. data/Rakefile +6 -1
  7. data/accelerator/bin/SHA256SUMS +6 -0
  8. data/accelerator/bin/rubydb-accelerator-darwin-amd64 +0 -0
  9. data/accelerator/bin/rubydb-accelerator-darwin-arm64 +0 -0
  10. data/accelerator/bin/rubydb-accelerator-linux-amd64 +0 -0
  11. data/accelerator/bin/rubydb-accelerator-linux-arm64 +0 -0
  12. data/accelerator/bin/rubydb-accelerator-windows-amd64.exe +0 -0
  13. data/accelerator/bin/rubydb-accelerator-windows-arm64.exe +0 -0
  14. data/accelerator/cmd/rubydb-accelerator/main.go +11 -0
  15. data/accelerator/go.mod +3 -0
  16. data/accelerator/internal/execution/aggregate.go +94 -0
  17. data/accelerator/internal/execution/distinct.go +22 -0
  18. data/accelerator/internal/execution/filter.go +73 -0
  19. data/accelerator/internal/execution/join.go +79 -0
  20. data/accelerator/internal/execution/operators.go +167 -0
  21. data/accelerator/internal/execution/scan.go +20 -0
  22. data/accelerator/internal/execution/sort.go +62 -0
  23. data/accelerator/internal/execution/types.go +136 -0
  24. data/accelerator/internal/execution/value.go +67 -0
  25. data/accelerator/internal/memory/arena.go +47 -0
  26. data/accelerator/internal/memory/reuse.go +22 -0
  27. data/accelerator/internal/metrics/registry.go +67 -0
  28. data/accelerator/internal/parallel/bounded_queue.go +56 -0
  29. data/accelerator/internal/parallel/scheduler.go +47 -0
  30. data/accelerator/internal/parallel/worker_pool.go +53 -0
  31. data/accelerator/internal/protocol/cancellation.go +48 -0
  32. data/accelerator/internal/protocol/columnar.go +263 -0
  33. data/accelerator/internal/protocol/frame.go +187 -0
  34. data/accelerator/internal/runtime/worker.go +521 -0
  35. data/accelerator/internal/storage/page_reader.go +81 -0
  36. data/accelerator/internal/storage/snapshot_scan.go +539 -0
  37. data/accelerator/internal/wal/checksum.go +13 -0
  38. data/accelerator/internal/wal/compression.go +41 -0
  39. data/accelerator/internal/wal/group_commit.go +24 -0
  40. data/accelerator/internal/wal/record_encoder.go +40 -0
  41. data/adapters/activerecord/README.md +8 -3
  42. data/adapters/activerecord/lib/active_record/connection_adapters/rubydb_adapter.rb +50 -42
  43. data/adapters/activerecord/rubydb-activerecord.gemspec +1 -1
  44. data/docs/README.md +3 -1
  45. data/docs/architecture/go-accelerator.md +179 -0
  46. data/docs/cli.md +24 -0
  47. data/docs/contributing/benchmarking.md +16 -0
  48. data/docs/developer/local-development.md +32 -0
  49. data/docs/release.md +2 -2
  50. data/lessons/02-local-development.md +2 -2
  51. data/lessons/04-rails-complex-apps.md +2 -2
  52. data/lessons/05-rubydb-production-server.md +2 -2
  53. data/lessons/07-hybrid-microservices.md +175 -90
  54. data/lessons/10-release-readiness.md +184 -117
  55. data/lessons/11-community-adapter.md +323 -0
  56. data/lessons/12-rails-ecommerce-pressure.md +263 -0
  57. data/lib/rubydb/accelerator/client.rb +451 -0
  58. data/lib/rubydb/accelerator/error.rb +22 -0
  59. data/lib/rubydb/accelerator/manager.rb +606 -0
  60. data/lib/rubydb/accelerator.rb +13 -0
  61. data/lib/rubydb/cli/application.rb +6 -1
  62. data/lib/rubydb/cli/commands/accelerator.rb +72 -0
  63. data/lib/rubydb/cli/commands/doctor.rb +3 -0
  64. data/lib/rubydb/client/client.rb +7 -0
  65. data/lib/rubydb/client/connection.rb +15 -0
  66. data/lib/rubydb/client/result.rb +5 -1
  67. data/lib/rubydb/configuration/defaults.rb +12 -0
  68. data/lib/rubydb/configuration/validation.rb +8 -1
  69. data/lib/rubydb/execution/accelerator_dispatch.rb +30 -0
  70. data/lib/rubydb/execution/cost_model.rb +72 -0
  71. data/lib/rubydb/execution/executor.rb +373 -11
  72. data/lib/rubydb/execution/operator_selection.rb +57 -0
  73. data/lib/rubydb/execution/physical_plan.rb +47 -0
  74. data/lib/rubydb/execution/planner.rb +12 -46
  75. data/lib/rubydb/execution/sort_executor.rb +22 -8
  76. data/lib/rubydb/indexes/btree.rb +31 -2
  77. data/lib/rubydb/rubydb.rb +7 -1
  78. data/lib/rubydb/server/session.rb +45 -0
  79. data/lib/rubydb/storage/engine.rb +74 -12
  80. data/lib/rubydb/storage/snapshot_reader.rb +167 -0
  81. data/lib/rubydb/version.rb +1 -1
  82. data/lib/rubydb/wal/archive.rb +17 -0
  83. data/lib/rubydb/wal/wal.rb +1 -0
  84. data/rubydb.gemspec +12 -2
  85. data/scripts/build_accelerator +49 -0
  86. data/scripts/release +34 -4
  87. data/scripts/replication_failover_drill +2 -2
  88. metadata +49 -1
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "bigdecimal"
5
+ require "date"
6
+ require "json"
7
+ require "time"
8
+
9
+ module RubyDB
10
+ module Accelerator
11
+ # High-level operations that are safe to delegate. The methods return nil
12
+ # in auto mode when no compatible binary is installed, allowing callers to
13
+ # execute the existing Ruby path without changing semantics.
14
+ class Client
15
+ attr_reader :manager
16
+
17
+ def initialize(config = {})
18
+ @manager = Manager.new(config || {})
19
+ end
20
+
21
+ def available?
22
+ @manager.available?
23
+ end
24
+
25
+ def enabled?
26
+ @manager.enabled?
27
+ end
28
+
29
+ def min_rows
30
+ @manager.min_rows
31
+ end
32
+
33
+ def read_pipeline?
34
+ return false if @manager.mode == "off"
35
+ return true if @manager.mode == "required"
36
+
37
+ @manager.read_pipeline == "on" && @manager.available?
38
+ end
39
+
40
+ def stats
41
+ @manager.stats
42
+ end
43
+
44
+ def restart
45
+ @manager.restart
46
+ end
47
+
48
+ def ping
49
+ @manager.request("ping")
50
+ end
51
+
52
+ def sha256(data)
53
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ payload = @manager.request("sha256", {data_base64: Base64.strict_encode64(data.to_s.b)})
55
+ @manager.observe_performance(:checksum, milliseconds: elapsed_ms(started), rows: data.to_s.bytesize)
56
+ payload&.fetch("digest")
57
+ end
58
+
59
+ def gzip(data, level: nil)
60
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ options = {data_base64: Base64.strict_encode64(data.to_s.b)}
62
+ options[:level] = level if level
63
+ payload = @manager.request("gzip", options)
64
+ @manager.observe_performance(:compression, milliseconds: elapsed_ms(started), rows: data.to_s.bytesize)
65
+ payload && Base64.strict_decode64(payload.fetch("data_base64"))
66
+ end
67
+
68
+ def gunzip(data)
69
+ payload = @manager.request("gunzip", {data_base64: Base64.strict_encode64(data.to_s.b)})
70
+ payload && Base64.strict_decode64(payload.fetch("data_base64"))
71
+ end
72
+
73
+ def rows_pipeline(rows, filters: [], order_by: [], group_by: [], aggregates: [], having: [],
74
+ projection: [], distinct: false, distinct_columns: [], windows: [], batch_size: nil, limit: nil, offset: 0)
75
+ if rows.length > @manager.max_rows
76
+ raise Error.new("accelerator row limit exceeded", code: "resource_limit") if @manager.mode == "required"
77
+
78
+ return nil
79
+ end
80
+ if read_pipeline? && binary_rows_safe?(rows)
81
+ return binary_rows_pipeline(rows, filters: filters, order_by: order_by, group_by: group_by,
82
+ aggregates: aggregates, having: having, projection: projection, distinct: distinct,
83
+ distinct_columns: distinct_columns, windows: windows, batch_size: batch_size,
84
+ limit: limit, offset: offset)
85
+ end
86
+
87
+ json_rows_pipeline(rows, filters: filters, order_by: order_by, group_by: group_by,
88
+ aggregates: aggregates, having: having, projection: projection, distinct: distinct,
89
+ distinct_columns: distinct_columns, windows: windows, batch_size: batch_size,
90
+ limit: limit, offset: offset)
91
+ rescue RubyDB::Accelerator::Error
92
+ raise if @manager.mode == "required"
93
+
94
+ json_rows_pipeline(rows, filters: filters, order_by: order_by, group_by: group_by,
95
+ aggregates: aggregates, limit: limit, offset: offset)
96
+ end
97
+
98
+ def binary_rows_pipeline(rows, filters:, order_by:, group_by:, aggregates:, having:, projection:, distinct:, distinct_columns:, windows:, batch_size:, limit:, offset:)
99
+ specification = JSON.generate(
100
+ filters: filters,
101
+ order_by: order_by,
102
+ group_by: group_by,
103
+ aggregates: aggregates,
104
+ having: having,
105
+ projection: projection,
106
+ distinct: distinct,
107
+ distinct_columns: distinct_columns,
108
+ windows: windows,
109
+ batch_size: batch_size,
110
+ offset: offset,
111
+ limit: limit
112
+ )
113
+ payload = [specification.bytesize].pack("V") + specification.b + encode_columnar_rows(rows)
114
+ response = @manager.binary_request("rows_pipeline", payload)
115
+ decode_binary_result(response)
116
+ end
117
+
118
+ def json_rows_pipeline(rows, filters:, order_by:, group_by:, aggregates:, having:, projection:, distinct:, distinct_columns:, windows:, batch_size:, limit:, offset:)
119
+ payload = {
120
+ rows: rows,
121
+ filters: filters,
122
+ order_by: order_by,
123
+ group_by: group_by,
124
+ aggregates: aggregates,
125
+ having: having,
126
+ projection: projection,
127
+ distinct: distinct,
128
+ distinct_columns: distinct_columns,
129
+ windows: windows,
130
+ batch_size: batch_size,
131
+ offset: offset
132
+ }
133
+ payload[:limit] = limit unless limit.nil?
134
+ response = @manager.request("rows_pipeline", payload)
135
+ response&.transform_keys(&:to_sym)
136
+ end
137
+
138
+ def rows_pipeline_stream(rows, filters: [], order_by: [], group_by: [], aggregates: [], having: [],
139
+ projection: [], distinct: false, distinct_columns: [], windows: [], batch_size: 1024, limit: nil, offset: 0)
140
+ if rows.length > @manager.max_rows
141
+ raise Error.new("accelerator row limit exceeded", code: "resource_limit") if @manager.mode == "required"
142
+
143
+ return nil
144
+ end
145
+ payload = {
146
+ rows: rows,
147
+ filters: filters,
148
+ order_by: order_by,
149
+ group_by: group_by,
150
+ aggregates: aggregates,
151
+ having: having,
152
+ projection: projection,
153
+ distinct: distinct,
154
+ distinct_columns: distinct_columns,
155
+ windows: windows,
156
+ batch_size: batch_size,
157
+ offset: offset
158
+ }
159
+ payload[:limit] = limit unless limit.nil?
160
+ @manager.request_stream("rows_pipeline_stream", payload)&.transform_keys(&:to_sym)
161
+ rescue RubyDB::Accelerator::Error
162
+ raise if @manager.mode == "required"
163
+
164
+ nil
165
+ end
166
+
167
+ def wal_batch(records, compress: false)
168
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
169
+ payload = {
170
+ records: Array(records).map do |record|
171
+ record = record.transform_keys(&:to_sym)
172
+ {
173
+ kind: Integer(record.fetch(:kind)),
174
+ lsn: Integer(record.fetch(:lsn)),
175
+ transaction: Integer(record.fetch(:transaction, 0)),
176
+ payload_base64: Base64.strict_encode64(record.fetch(:payload).to_s.b)
177
+ }
178
+ end,
179
+ compress: compress
180
+ }
181
+ result = @manager.request("wal_batch", payload)
182
+ @manager.observe_performance(:wal_batch, milliseconds: elapsed_ms(started), rows: records.length)
183
+ result&.transform_keys(&:to_sym)
184
+ rescue KeyError, TypeError, ArgumentError => error
185
+ raise Error, "Invalid WAL batch: #{error.message}"
186
+ end
187
+
188
+ def hash_join(left_rows, right_rows, left_key:, right_key:, join_type: "inner")
189
+ return nil unless read_pipeline? && binary_rows_safe?(left_rows) && binary_rows_safe?(right_rows)
190
+
191
+ specification = JSON.generate(left_key: left_key.to_s, right_key: right_key.to_s, join_type: join_type.to_s, algorithm: "hash")
192
+ left_batch = encode_columnar_rows(left_rows)
193
+ right_batch = encode_columnar_rows(right_rows)
194
+ payload = [specification.bytesize].pack("V") + specification.b +
195
+ [left_batch.bytesize].pack("V") + left_batch +
196
+ [right_batch.bytesize].pack("V") + right_batch
197
+ decode_binary_result(@manager.binary_request("hash_join", payload))
198
+ rescue RubyDB::Accelerator::Error
199
+ raise if @manager.mode == "required"
200
+
201
+ nil
202
+ end
203
+
204
+ def merge_join(left_rows, right_rows, left_key:, right_key:, join_type: "inner")
205
+ return nil unless read_pipeline? && binary_rows_safe?(left_rows) && binary_rows_safe?(right_rows)
206
+ return nil if left_rows.length + right_rows.length > @manager.max_rows
207
+
208
+ specification = JSON.generate(left_key: left_key.to_s, right_key: right_key.to_s,
209
+ join_type: join_type.to_s, algorithm: "merge")
210
+ left_batch = encode_columnar_rows(left_rows)
211
+ right_batch = encode_columnar_rows(right_rows)
212
+ payload = [specification.bytesize].pack("V") + specification.b +
213
+ [left_batch.bytesize].pack("V") + left_batch +
214
+ [right_batch.bytesize].pack("V") + right_batch
215
+ decode_binary_result(@manager.binary_request("hash_join", payload))
216
+ rescue RubyDB::Accelerator::Error
217
+ raise if @manager.mode == "required"
218
+
219
+ nil
220
+ end
221
+
222
+ # Scan an immutable storage snapshot. The request contains no Ruby row
223
+ # hashes; Go opens the snapshot file, decodes pages, applies predicates,
224
+ # and returns a bounded columnar result.
225
+ def snapshot_scan(manifest, table:, columns:, column_types:, filters: [], order_by: [], index_name: nil,
226
+ distinct: false, distinct_columns: [], limit: nil, offset: 0, batch_size: nil)
227
+ return nil unless read_pipeline?
228
+
229
+ specification = {
230
+ snapshot: manifest,
231
+ table: table.to_s,
232
+ columns: Array(columns).map(&:to_s),
233
+ column_types: column_types.transform_keys(&:to_s).transform_values(&:to_s),
234
+ filters: filters,
235
+ order_by: order_by,
236
+ distinct: distinct,
237
+ distinct_columns: distinct_columns,
238
+ limit: limit,
239
+ offset: offset,
240
+ batch_size: batch_size,
241
+ max_rows: @manager.max_rows
242
+ }
243
+ specification[:index_name] = index_name.to_s if index_name
244
+ response = @manager.binary_request("snapshot_scan", JSON.generate(specification))
245
+ decode_binary_result(response, column_types: column_types)
246
+ rescue RubyDB::Accelerator::Error
247
+ raise if @manager.mode == "required"
248
+
249
+ nil
250
+ end
251
+
252
+ def binary_rows_safe?(rows)
253
+ rows.all? do |row|
254
+ row.all? { |_key, value| binary_value_safe?(value) }
255
+ end
256
+ end
257
+
258
+ def binary_value_safe?(value)
259
+ case value
260
+ when NilClass, TrueClass, FalseClass, String, Array, Hash
261
+ if value.is_a?(Array)
262
+ value.all? { |item| binary_value_safe?(item) }
263
+ elsif value.is_a?(Hash)
264
+ value.all? { |key, item| (key.is_a?(String) || key.is_a?(Symbol)) && binary_value_safe?(item) }
265
+ else
266
+ true
267
+ end
268
+ when Integer
269
+ value >= -(1 << 63) && value <= ((1 << 63) - 1)
270
+ when Float
271
+ value.finite?
272
+ else
273
+ false
274
+ end
275
+ end
276
+
277
+ def encode_columnar_rows(rows)
278
+ columns = []
279
+ seen = {}
280
+ rows.each do |row|
281
+ row.each_key do |key|
282
+ name = key.to_s
283
+ next if seen[name]
284
+
285
+ seen[name] = true
286
+ columns << name
287
+ end
288
+ end
289
+ output = [columns.length, rows.length].pack("vV")
290
+ columns.each do |column|
291
+ bytes = column.b
292
+ raise Error, "RubyDB accelerator column name is too long" if bytes.bytesize > 65_535
293
+
294
+ output << [bytes.bytesize].pack("v") << bytes
295
+ end
296
+ columns.each do |column|
297
+ rows.each do |row|
298
+ value = if row.key?(column)
299
+ row[column]
300
+ elsif row.key?(column.to_sym)
301
+ row[column.to_sym]
302
+ end
303
+ output << encode_binary_value(value)
304
+ end
305
+ end
306
+ output
307
+ end
308
+
309
+ def encode_binary_value(value)
310
+ case value
311
+ when nil
312
+ [0].pack("C")
313
+ when true, false
314
+ [1, value ? 1 : 0].pack("C2")
315
+ when Integer
316
+ [2].pack("C") + [value].pack("q<")
317
+ when Float
318
+ [3].pack("C") + [value].pack("E")
319
+ when String
320
+ bytes = value.b
321
+ [4].pack("C") + [bytes.bytesize].pack("V") + bytes
322
+ when Array, Hash
323
+ encoded = JSON.generate(value)
324
+ [6].pack("C") + [encoded.bytesize].pack("V") + encoded.b
325
+ else
326
+ raise Error, "RubyDB accelerator cannot encode #{value.class} in a binary row batch"
327
+ end
328
+ end
329
+
330
+ def decode_binary_result(data, column_types: nil)
331
+ offset = 0
332
+ metadata_length = read_uint32(data, offset)
333
+ offset += 4
334
+ metadata = JSON.parse(data.byteslice(offset, metadata_length))
335
+ offset += metadata_length
336
+ row_length = read_uint32(data, offset)
337
+ offset += 4
338
+ rows = decode_columnar_rows(data.byteslice(offset, row_length))
339
+ rows = restore_snapshot_types(rows, column_types || metadata["column_types"])
340
+ offset += row_length
341
+ aggregate_length = read_uint32(data, offset)
342
+ offset += 4
343
+ aggregates = decode_columnar_rows(data.byteslice(offset, aggregate_length))
344
+ {
345
+ rows: rows,
346
+ row_count: metadata.fetch("row_count", rows.length),
347
+ aggregates: metadata["has_aggregates"] ? aggregates : nil
348
+ }
349
+ rescue JSON::ParserError, TypeError, ArgumentError, RangeError => error
350
+ raise ProtocolError.new("Invalid RubyDB accelerator binary result: #{error.message}", code: "invalid_response")
351
+ end
352
+
353
+ def restore_snapshot_types(rows, column_types)
354
+ return rows unless column_types.is_a?(Hash) && !column_types.empty?
355
+
356
+ rows.map do |row|
357
+ row.each_with_object({}) do |(column, value), restored|
358
+ type = column_types[column] || column_types[column.to_sym]
359
+ restored[column] = restore_snapshot_value(value, type)
360
+ end
361
+ end
362
+ end
363
+
364
+ def restore_snapshot_value(value, type)
365
+ return value if value.nil? || type.nil?
366
+
367
+ case type.to_s.downcase
368
+ when "date"
369
+ Date.parse(value.to_s)
370
+ when "time", "timestamp"
371
+ Time.parse(value.to_s)
372
+ when "decimal"
373
+ BigDecimal(value.to_s)
374
+ when "json"
375
+ value.is_a?(String) ? JSON.parse(value) : value
376
+ else
377
+ value
378
+ end
379
+ rescue ArgumentError, TypeError, JSON::ParserError
380
+ value
381
+ end
382
+
383
+ def decode_columnar_rows(data)
384
+ offset = 0
385
+ column_count = read_uint16(data, offset)
386
+ offset += 2
387
+ row_count = read_uint32(data, offset)
388
+ offset += 4
389
+ columns = column_count.times.map do
390
+ length = read_uint16(data, offset)
391
+ offset += 2
392
+ value = data.byteslice(offset, length).to_s
393
+ offset += length
394
+ value
395
+ end
396
+ rows = Array.new(row_count) { {} }
397
+ columns.each do |column|
398
+ row_count.times do |index|
399
+ value, consumed = decode_binary_value(data, offset)
400
+ offset += consumed
401
+ rows[index][column] = value
402
+ end
403
+ end
404
+ raise ProtocolError.new("Binary row batch has trailing bytes", code: "invalid_response") unless offset == data.bytesize
405
+
406
+ rows
407
+ end
408
+
409
+ def decode_binary_value(data, offset)
410
+ tag = data.getbyte(offset)
411
+ raise RangeError, "missing binary value tag" unless tag
412
+
413
+ case tag
414
+ when 0 then [nil, 1]
415
+ when 1 then [data.getbyte(offset + 1) == 1, 2]
416
+ when 2 then [data.byteslice(offset + 1, 8).unpack1("q<"), 9]
417
+ when 3 then [data.byteslice(offset + 1, 8).unpack1("E"), 9]
418
+ when 4
419
+ length = data.byteslice(offset + 1, 4).unpack1("V")
420
+ [data.byteslice(offset + 5, length).to_s.force_encoding(Encoding::UTF_8), 5 + length]
421
+ when 5
422
+ length = data.byteslice(offset + 1, 4).unpack1("V")
423
+ [data.byteslice(offset + 5, length).to_s.b, 5 + length]
424
+ when 6
425
+ length = data.byteslice(offset + 1, 4).unpack1("V")
426
+ [JSON.parse(data.byteslice(offset + 5, length)), 5 + length]
427
+ else
428
+ raise RangeError, "unknown binary value tag #{tag}"
429
+ end
430
+ end
431
+
432
+ def read_uint16(data, offset)
433
+ data.byteslice(offset, 2).unpack1("v")
434
+ end
435
+
436
+ def read_uint32(data, offset)
437
+ data.byteslice(offset, 4).unpack1("V")
438
+ end
439
+
440
+ def close
441
+ @manager.close
442
+ end
443
+
444
+ private
445
+
446
+ def elapsed_ms(started)
447
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
448
+ end
449
+ end
450
+ end
451
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDB
4
+ module Accelerator
5
+ class Error < StandardError
6
+ attr_reader :code
7
+
8
+ def initialize(message, code: nil)
9
+ @code = code
10
+ super(message)
11
+ end
12
+ end
13
+
14
+ class UnavailableError < Error; end
15
+
16
+ class ProtocolError < Error; end
17
+
18
+ class RequestError < Error; end
19
+
20
+ class TimeoutError < Error; end
21
+ end
22
+ end