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
@@ -97,11 +97,11 @@ module RubyDB
97
97
  comparison = 0
98
98
  order_by.each do |order|
99
99
  col = order.column.to_s
100
- val_a = a[col]
101
- val_b = b[col]
100
+ val_a = order_value(a, col)
101
+ val_b = order_value(b, col)
102
102
 
103
103
  comparison = compare_values(val_a, val_b)
104
- comparison = -comparison if order.direction == :desc
104
+ comparison = -comparison if order.direction.to_s.casecmp?("desc")
105
105
  break unless comparison == 0
106
106
  end
107
107
  comparison
@@ -112,9 +112,11 @@ module RubyDB
112
112
  return 0 if a.nil? && b.nil?
113
113
  return -1 if a.nil?
114
114
  return 1 if b.nil?
115
- if a.is_a?(String) && b.is_a?(String)
116
- end
117
- b
115
+
116
+ comparison = a <=> b
117
+ return comparison unless comparison.nil?
118
+
119
+ a.to_s <=> b.to_s
118
120
  end
119
121
 
120
122
  def external_sort(rows, order_by, temp_dir)
@@ -166,14 +168,26 @@ module RubyDB
166
168
  def compare_rows(a, b, order_by)
167
169
  order_by.each do |order|
168
170
  col = order.column.to_s
169
- val_a = a[col]
170
- val_b = b[col]
171
+ val_a = order_value(a, col)
172
+ val_b = order_value(b, col)
171
173
  comparison = compare_values(val_a, val_b)
174
+ comparison = -comparison if order.direction.to_s.casecmp?("desc")
172
175
  return comparison unless comparison == 0
173
176
  end
174
177
  0
175
178
  end
176
179
 
180
+ def order_value(row, column)
181
+ return row[column] if row.key?(column)
182
+ return row[column.to_sym] if row.key?(column.to_sym)
183
+
184
+ short_column = column.split(".").last
185
+ return row[short_column] if row.key?(short_column)
186
+ return row[short_column.to_sym] if row.key?(short_column.to_sym)
187
+
188
+ nil
189
+ end
190
+
177
191
  attr_reader :stats
178
192
  end
179
193
  end
@@ -133,6 +133,27 @@ module RubyDB
133
133
  })
134
134
  end
135
135
 
136
+ # Return a stable, ordered copy of the in-memory B-tree entries. Indexes
137
+ # are rebuilt from metadata on open, so this immutable value is the
138
+ # correct hand-off to a storage snapshot; it is not a second persisted
139
+ # index format.
140
+ def snapshot_entries
141
+ @lock.synchronize do
142
+ return [] unless @root
143
+
144
+ leaf = @root
145
+ leaf = leaf.children.first until leaf.is_leaf
146
+ entries = []
147
+ while leaf
148
+ leaf.keys.each_with_index do |key, index|
149
+ entries << {key: key, value: leaf.values[index]}
150
+ end
151
+ leaf = leaf.next_leaf
152
+ end
153
+ entries
154
+ end
155
+ end
156
+
136
157
  private
137
158
 
138
159
  def initialize_root
@@ -151,9 +172,17 @@ module RubyDB
151
172
  def extract_key(row)
152
173
  if @columns.size == 1
153
174
  column = @columns.first
154
- row.key?(column) ? row[column] : row[column.to_s]
175
+ return row[column] if row.key?(column)
176
+ return row[column.to_s] if row.key?(column.to_s)
177
+
178
+ row[column.to_sym] if column.respond_to?(:to_sym)
155
179
  else
156
- @columns.map { |col| row.key?(col) ? row[col] : row[col.to_s] }
180
+ @columns.map do |column|
181
+ next row[column] if row.key?(column)
182
+ next row[column.to_s] if row.key?(column.to_s)
183
+
184
+ column.respond_to?(:to_sym) ? row[column.to_sym] : nil
185
+ end
157
186
  end
158
187
  end
159
188
 
data/lib/rubydb/rubydb.rb CHANGED
@@ -12,6 +12,7 @@ require "set"
12
12
  require_relative "version"
13
13
  require_relative "constants"
14
14
  require_relative "build_info"
15
+ require_relative "accelerator"
15
16
 
16
17
  require_relative "errors/error"
17
18
  require_relative "errors/connection_error"
@@ -77,8 +78,12 @@ require_relative "sql/planner/binder"
77
78
  require_relative "sql/planner/analyzer"
78
79
  require_relative "sql/planner/type_checker"
79
80
 
80
- require_relative "execution/executor"
81
81
  require_relative "execution/plan"
82
+ require_relative "execution/physical_plan"
83
+ require_relative "execution/cost_model"
84
+ require_relative "execution/operator_selection"
85
+ require_relative "execution/accelerator_dispatch"
86
+ require_relative "execution/executor"
82
87
  require_relative "execution/planner"
83
88
  require_relative "execution/optimizer"
84
89
  require_relative "execution/expression"
@@ -302,6 +307,7 @@ require_relative "cli/commands/diff"
302
307
  require_relative "cli/commands/inspect"
303
308
  require_relative "cli/commands/vacuum"
304
309
  require_relative "cli/commands/doctor"
310
+ require_relative "cli/commands/accelerator"
305
311
 
306
312
  module RubyDB
307
313
  # Main database class
@@ -80,6 +80,8 @@ module RubyDB
80
80
  case request[:type]
81
81
  when "query"
82
82
  process_query(request[:sql], request[:params] || [], request[:deadline_at], cancellation)
83
+ when "metadata"
84
+ process_metadata(request[:table])
83
85
  when "prepare"
84
86
  process_prepare(request[:sql])
85
87
  when "execute"
@@ -195,6 +197,49 @@ module RubyDB
195
197
  }
196
198
  end
197
199
 
200
+ def process_metadata(table_name = nil)
201
+ engine = @config[:engine]
202
+ raise RubyDB::ServerError, "Session has no database engine" unless engine
203
+
204
+ if table_name.nil? || table_name.to_s.empty?
205
+ return {
206
+ success: true,
207
+ type: "metadata_result",
208
+ metadata: {tables: engine.list_tables.map(&:to_s)},
209
+ timestamp: Time.now.iso8601
210
+ }
211
+ end
212
+
213
+ table_key = table_name.to_s
214
+ metadata = engine.table_metadata[table_key] || engine.table_metadata[table_key.to_sym]
215
+ raise RubyDB::DatabaseError, "Table '#{table_name}' does not exist" unless metadata
216
+
217
+ columns = engine.table_columns(table_key).map do |column|
218
+ {
219
+ name: column.name.to_s,
220
+ type: column.type.to_s,
221
+ nullable: column.nullable?,
222
+ primary_key: column.primary_key?,
223
+ default: column.has_default? ? column.default : nil
224
+ }
225
+ end
226
+ indexes = engine.index_manager.get_indexes_for_table(table_key).map do |index|
227
+ {name: index.name.to_s, unique: index.unique, columns: index.columns.map(&:to_s)}
228
+ end
229
+
230
+ {
231
+ success: true,
232
+ type: "metadata_result",
233
+ metadata: {
234
+ tables: [table_key],
235
+ columns: columns,
236
+ indexes: indexes,
237
+ constraints: metadata[:constraints] || []
238
+ },
239
+ timestamp: Time.now.iso8601
240
+ }
241
+ end
242
+
198
243
  def process_prepare(sql)
199
244
  permission_error = authorize_sql(sql)
200
245
  return permission_error if permission_error
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
  require "fileutils"
7
7
  require "monitor"
8
+ require_relative "../accelerator"
8
9
 
9
10
  # Storage components
10
11
  require_relative "database_lock"
@@ -17,6 +18,7 @@ require_relative "tuple"
17
18
  require_relative "serializer"
18
19
  require_relative "deserializer"
19
20
  require_relative "visibility_map"
21
+ require_relative "snapshot_reader"
20
22
  require_relative "../mvcc/version_store"
21
23
  require_relative "storage_layout"
22
24
  require_relative "../indexes/index"
@@ -53,12 +55,13 @@ module RubyDB
53
55
  NULL_BITMAP_FLAG = 0x02
54
56
  VARIABLE_LENGTH_PREFIXES_FLAG = 0x04
55
57
 
56
- attr_reader :storage_manager, :buffer_pool, :page_manager, :catalog, :path, :last_commit_ack
57
- attr_reader :table_metadata, :transaction_manager, :wal, :crash_recovery, :version_store, :index_manager
58
+ attr_reader :storage_manager, :buffer_pool, :page_manager, :catalog, :path, :last_commit_ack, :config, :accelerator
59
+ attr_reader :table_metadata, :transaction_manager, :wal, :crash_recovery, :version_store, :index_manager, :snapshot_reader
58
60
 
59
61
  def initialize(path, config = {})
60
62
  @path = path
61
63
  @config = config
64
+ @accelerator = RubyDB::Accelerator::Client.new(config[:accelerator] || {})
62
65
  @database_lock = DatabaseLock.new(path)
63
66
  @database_lock.acquire!
64
67
  @catalog = config[:catalog] || Catalog::Catalog.new
@@ -117,7 +120,7 @@ module RubyDB
117
120
 
118
121
  # Initialize WAL
119
122
  wal_dir = config[:wal_dir] || "#{path}.wal"
120
- @wal = WAL::WAL.new(wal_dir, recovery: false) # Defer recovery until after metadata load
123
+ @wal = WAL::WAL.new(wal_dir, config.merge(recovery: false)) # Defer recovery until after metadata load
121
124
  @wal.attach_engine(self)
122
125
 
123
126
  # Initialize crash recovery
@@ -131,6 +134,7 @@ module RubyDB
131
134
 
132
135
  # Build/load indexes only after metadata and crash recovery are ready.
133
136
  @index_manager = Indexes::IndexManager.new(self)
137
+ @snapshot_reader = SnapshotReader.new(self)
134
138
 
135
139
  # Start cleanup thread if configured
136
140
  start_cleanup_thread if config[:auto_cleanup] != false
@@ -168,6 +172,31 @@ module RubyDB
168
172
  @storage_manager.free_page(page_number)
169
173
  end
170
174
 
175
+ # Yield a stable, immutable database snapshot to the accelerator. A
176
+ # transaction or an active MVCC writer makes the snapshot ineligible;
177
+ # callers use the normal Ruby path in that case.
178
+ def with_accelerator_snapshot
179
+ @lock.synchronize do
180
+ return nil unless accelerator_snapshot_eligible?
181
+
182
+ @storage_manager.flush
183
+ @visibility_map.flush
184
+ save_table_metadata
185
+ @snapshot_reader.with_snapshot { |snapshot| yield snapshot }
186
+ end
187
+ end
188
+
189
+ def accelerator_snapshot_hidden_row_ids
190
+ @visibility_map.visibility_info.each_with_object([]) do |(row_id, info), hidden|
191
+ state = (info[:state] || info["state"]).to_s.to_sym
192
+ hidden << row_id.to_i unless %i[visible committed].include?(state)
193
+ end
194
+ end
195
+
196
+ def table_pages_for_snapshot(table_name)
197
+ @table_pages[resolve_table_name(table_name)] || []
198
+ end
199
+
171
200
  # Record operations
172
201
  def read_record(page_number, offset, length)
173
202
  @storage_manager.read_record(page_number, offset, length)
@@ -1483,6 +1512,14 @@ module RubyDB
1483
1512
  current_transaction_manager && current_transaction_manager[:active]
1484
1513
  end
1485
1514
 
1515
+ def accelerator_snapshot_eligible?
1516
+ return false unless @is_open
1517
+ return false if in_transaction?
1518
+ return false unless @visibility_map.active_transaction_count.zero?
1519
+
1520
+ current_transaction_id.to_i.zero?
1521
+ end
1522
+
1486
1523
  def create_savepoint(name)
1487
1524
  @lock.synchronize do
1488
1525
  tx = current_transaction_manager
@@ -1613,6 +1650,8 @@ module RubyDB
1613
1650
  return unless @is_open
1614
1651
 
1615
1652
  stop_cleanup_thread
1653
+ @accelerator&.close
1654
+ @snapshot_reader&.close
1616
1655
 
1617
1656
  # Flush WAL first (ensures all mutations are recorded)
1618
1657
  if @wal
@@ -1653,6 +1692,7 @@ module RubyDB
1653
1692
  transaction_rollback: @stats[:transaction_rollback],
1654
1693
  wal_writes: @stats[:wal_writes],
1655
1694
  crash_recoveries: @stats[:crash_recoveries],
1695
+ accelerator: @accelerator&.stats,
1656
1696
  last_maintenance_error: @last_maintenance_error,
1657
1697
  is_open: @is_open,
1658
1698
  visibility_rows: @visibility_map.visibility_info.size,
@@ -2092,7 +2132,7 @@ module RubyDB
2092
2132
  end
2093
2133
 
2094
2134
  def validate_referential_update!(table_name, old_row, new_row)
2095
- return true if old_row == new_row
2135
+ return true if referential_row_values_equal?(table_name, old_row, new_row)
2096
2136
 
2097
2137
  @table_metadata.each do |child_table, child_metadata|
2098
2138
  (child_metadata[:constraints] || []).each do |raw_definition|
@@ -2102,13 +2142,13 @@ module RubyDB
2102
2142
 
2103
2143
  child_columns = Array(definition[:columns]).map(&:to_sym)
2104
2144
  parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2105
- next unless parent_columns.any? { |column| old_row[column] != new_row[column] }
2145
+ next unless parent_columns.any? { |column| row_value(old_row, column) != row_value(new_row, column) }
2106
2146
 
2107
2147
  child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2108
2148
  referenced = child_rows.any? do |child_row|
2109
2149
  child_columns.each_with_index.all? do |child_column, index|
2110
- child_value = child_row[child_column] || child_row[child_column.to_s]
2111
- parent_value = old_row[parent_columns[index]] || old_row[parent_columns[index].to_s]
2150
+ child_value = row_value(child_row, child_column)
2151
+ parent_value = row_value(old_row, parent_columns[index])
2112
2152
  !child_value.nil? && child_value == parent_value
2113
2153
  end
2114
2154
  end
@@ -2134,7 +2174,7 @@ module RubyDB
2134
2174
  end
2135
2175
 
2136
2176
  def apply_referential_update!(table_name, old_row, new_row)
2137
- return true if old_row == new_row
2177
+ return true if referential_row_values_equal?(table_name, old_row, new_row)
2138
2178
 
2139
2179
  @table_metadata.each do |child_table, child_metadata|
2140
2180
  (child_metadata[:constraints] || []).each do |raw_definition|
@@ -2144,12 +2184,12 @@ module RubyDB
2144
2184
 
2145
2185
  child_columns = Array(definition[:columns]).map(&:to_sym)
2146
2186
  parent_columns = Array(definition[:reference_columns] || :id).map(&:to_sym)
2147
- next unless parent_columns.any? { |column| old_row[column] != new_row[column] }
2187
+ next unless parent_columns.any? { |column| row_value(old_row, column) != row_value(new_row, column) }
2148
2188
  child_rows = select_rows(child_table, child_metadata[:columns], visibility_check: false)
2149
2189
  matching_rows = child_rows.select do |child_row|
2150
2190
  child_columns.each_with_index.all? do |child_column, index|
2151
- child_value = child_row[child_column] || child_row[child_column.to_s]
2152
- parent_value = old_row[parent_columns[index]] || old_row[parent_columns[index].to_s]
2191
+ child_value = row_value(child_row, child_column)
2192
+ parent_value = row_value(old_row, parent_columns[index])
2153
2193
  !child_value.nil? && child_value == parent_value
2154
2194
  end
2155
2195
  end
@@ -2160,7 +2200,7 @@ module RubyDB
2160
2200
  child_row_id = child_row[:_row_id] || child_row["_row_id"]
2161
2201
  updates = case action
2162
2202
  when "cascade"
2163
- child_columns.each_with_index.to_h { |column, index| [column, new_row[parent_columns[index]] || new_row[parent_columns[index].to_s]] }
2203
+ child_columns.each_with_index.to_h { |column, index| [column, row_value(new_row, parent_columns[index])] }
2164
2204
  when "set_null"
2165
2205
  child_columns.to_h { |column| [column, nil] }
2166
2206
  when "set_default"
@@ -2178,6 +2218,28 @@ module RubyDB
2178
2218
  true
2179
2219
  end
2180
2220
 
2221
+ # Rows can cross the storage, SQL, WAL, and recovery boundaries with
2222
+ # symbol or string keys. Referential actions compare column values, not
2223
+ # the Ruby key representation. Without this normalization, updating a
2224
+ # non-key parent column could look like a primary-key change during WAL
2225
+ # replay and incorrectly fail ON UPDATE RESTRICT.
2226
+ def row_value(row, column)
2227
+ return row[column] if row.key?(column)
2228
+ return row[column.to_s] if row.key?(column.to_s)
2229
+
2230
+ nil
2231
+ end
2232
+
2233
+ def referential_row_values_equal?(table_name, old_row, new_row)
2234
+ columns = @table_metadata[table_name.to_s] || @table_metadata[table_name.to_sym]
2235
+ column_definitions = columns && columns[:columns]
2236
+ return old_row == new_row if column_definitions.nil?
2237
+
2238
+ column_definitions.all? do |column|
2239
+ row_value(old_row, column.name.to_sym) == row_value(new_row, column.name.to_sym)
2240
+ end
2241
+ end
2242
+
2181
2243
  def cache_hit_rate
2182
2244
  total = @stats[:cache_hits] + @stats[:cache_misses]
2183
2245
  return 0.0 if total == 0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module RubyDB
9
+ module Storage
10
+ # Creates a validated snapshot manifest for the Go accelerator. The
11
+ # default is a lock-protected read of the already-flushed live file;
12
+ # operators can opt into a detached copy when they need one. Ruby remains
13
+ # responsible for locking, WAL durability, catalog state, and MVCC
14
+ # eligibility.
15
+ class SnapshotReader
16
+ FORMAT_VERSION = 1
17
+
18
+ def initialize(engine)
19
+ @engine = engine
20
+ @directory = "#{engine.path}.accelerator-snapshots"
21
+ @lock = Mutex.new
22
+ @active_paths = {}
23
+ end
24
+
25
+ def with_snapshot
26
+ snapshot = create
27
+ begin
28
+ yield snapshot
29
+ ensure
30
+ release(snapshot)
31
+ end
32
+ end
33
+
34
+ def close
35
+ @lock.synchronize do
36
+ @active_paths.keys.each { |path| delete_file(path) }
37
+ @active_paths.clear
38
+ Dir.rmdir(@directory) if Dir.exist?(@directory) && Dir.empty?(@directory)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def create
45
+ source = File.expand_path(@engine.path)
46
+ raise StorageError, "Cannot create accelerator snapshot: database file is missing" unless File.file?(source)
47
+
48
+ # Engine#with_accelerator_snapshot holds the engine lock for the whole
49
+ # yield, including the Go read. In that guarded section the live file
50
+ # is already flushed and no Ruby writer can mutate it, so copying the
51
+ # complete database before every read only adds latency and doubles
52
+ # the storage I/O. Keep an opt-out copy mode for operators that need a
53
+ # detached file, but make the lock-protected direct path the default.
54
+ return direct_manifest(source) if direct_snapshot?
55
+
56
+ FileUtils.mkdir_p(@directory)
57
+ destination = File.join(@directory, "snapshot-#{Process.pid}-#{SecureRandom.hex(12)}.db")
58
+ FileUtils.copy_file(source, destination, true)
59
+ File.open(destination, "r+b") do |file|
60
+ file.flush
61
+ file.fsync
62
+ end
63
+
64
+ snapshot = manifest_for(
65
+ destination,
66
+ snapshot_id: File.basename(destination, ".db"),
67
+ file_sha256: Digest::SHA256.file(destination).hexdigest,
68
+ temporary: true
69
+ )
70
+ @lock.synchronize { @active_paths[destination] = true }
71
+ snapshot
72
+ # Remove a partially copied snapshot even when the caller is interrupted.
73
+ # rubocop:disable Lint/RescueException
74
+ rescue Exception
75
+ delete_file(destination) if destination
76
+ raise
77
+ # rubocop:enable Lint/RescueException
78
+ end
79
+
80
+ def direct_manifest(source)
81
+ manifest_for(
82
+ source,
83
+ snapshot_id: "live-#{Process.pid}-#{SecureRandom.hex(8)}",
84
+ file_sha256: nil,
85
+ temporary: false
86
+ )
87
+ end
88
+
89
+ def manifest_for(path, snapshot_id:, file_sha256:, temporary:)
90
+ {
91
+ format_version: FORMAT_VERSION,
92
+ snapshot_id: snapshot_id,
93
+ snapshot_path: File.expand_path(path),
94
+ page_size: @engine.storage_manager.page_size,
95
+ page_count: @engine.storage_manager.file_manager.num_pages,
96
+ file_sha256: file_sha256,
97
+ tables: table_manifest,
98
+ hidden_row_ids: @engine.accelerator_snapshot_hidden_row_ids,
99
+ temporary: temporary
100
+ }
101
+ end
102
+
103
+ def direct_snapshot?
104
+ accelerator_config = @engine.config[:accelerator] || @engine.config["accelerator"] || {}
105
+ configured = accelerator_config[:direct_snapshot] || accelerator_config["direct_snapshot"]
106
+ configured = ENV.fetch("RUBYDB_ACCELERATOR_DIRECT_SNAPSHOT", "on") if configured.nil?
107
+ !%w[off false 0].include?(configured.to_s.downcase)
108
+ end
109
+
110
+ def table_manifest
111
+ @engine.table_metadata.each_with_object({}) do |(table_name, metadata), tables|
112
+ columns = Array(metadata[:columns]).map do |column|
113
+ {
114
+ name: column.name.to_s,
115
+ type: column.type_class.to_s,
116
+ nullable: column.nullable?,
117
+ default: json_value(column.default)
118
+ }
119
+ end
120
+ indexes = Array(@engine.index_manager&.get_indexes_for_table(table_name)).filter_map do |index|
121
+ next unless index.type.to_sym == :btree && index.respond_to?(:snapshot_entries)
122
+
123
+ {
124
+ name: index.name.to_s,
125
+ type: index.type.to_s,
126
+ columns: index.columns.map(&:to_s),
127
+ unique: index.unique,
128
+ entries: index.snapshot_entries.map do |entry|
129
+ {key: json_value(entry[:key]), row_id: entry[:value].to_i}
130
+ end
131
+ }
132
+ end
133
+
134
+ tables[table_name.to_s] = {
135
+ pages: Array(@engine.table_pages_for_snapshot(table_name)).map(&:to_i),
136
+ columns: columns,
137
+ indexes: indexes,
138
+ row_count: metadata[:row_count].to_i
139
+ }
140
+ end
141
+ end
142
+
143
+ def json_value(value)
144
+ JSON.parse(JSON.generate(value))
145
+ rescue
146
+ nil
147
+ end
148
+
149
+ def release(snapshot)
150
+ path = snapshot && snapshot[:snapshot_path]
151
+ return unless path && snapshot[:temporary]
152
+
153
+ @lock.synchronize do
154
+ @active_paths.delete(path)
155
+ delete_file(path)
156
+ Dir.rmdir(@directory) if Dir.exist?(@directory) && Dir.empty?(@directory)
157
+ end
158
+ end
159
+
160
+ def delete_file(path)
161
+ File.delete(path) if path && File.file?(path)
162
+ rescue SystemCallError
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
@@ -11,7 +11,7 @@ module RubyDB
11
11
  # - MAJOR: Incompatible API changes
12
12
  # - MINOR: Backwards-compatible new functionality
13
13
  # - PATCH: Backwards-compatible bug fixes
14
- VERSION = "0.1.5"
14
+ VERSION = "0.1.6"
15
15
 
16
16
  # Version components for easy access
17
17
  module Version
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "zlib"
5
5
  require "time"
6
+ require_relative "../accelerator"
6
7
 
7
8
  module RubyDB
8
9
  module WAL
@@ -14,6 +15,7 @@ module RubyDB
14
15
  @wal_dir = wal_dir
15
16
  @archive_dir = archive_dir || File.join(wal_dir, "archive")
16
17
  @config = config
18
+ @accelerator = RubyDB::Accelerator::Client.new(config[:accelerator] || {})
17
19
  @compression = config[:compression] || true
18
20
  @max_archive_size = config[:max_size] || 10 * 1024 * 1024 * 1024 # 10GB
19
21
  @retention_days = config[:retention_days] || 30
@@ -170,9 +172,19 @@ module RubyDB
170
172
  end
171
173
  end
172
174
 
175
+ def close
176
+ @accelerator&.close
177
+ true
178
+ end
179
+
173
180
  private
174
181
 
175
182
  def compress_file(source, destination)
183
+ if (compressed = @accelerator.gzip(File.binread(source), level: @config[:compression_level]))
184
+ File.binwrite(destination, compressed)
185
+ return
186
+ end
187
+
176
188
  File.open(source, "rb") do |input|
177
189
  Zlib::GzipWriter.open(destination) do |output|
178
190
  output.write(input.read)
@@ -181,6 +193,11 @@ module RubyDB
181
193
  end
182
194
 
183
195
  def decompress_file(source, destination)
196
+ if (decompressed = @accelerator.gunzip(File.binread(source)))
197
+ File.binwrite(destination, decompressed)
198
+ return
199
+ end
200
+
184
201
  Zlib::GzipReader.open(source) do |input|
185
202
  File.binwrite(destination, input.read)
186
203
  end
@@ -339,6 +339,7 @@ module RubyDB
339
339
  @checkpoint&.stop
340
340
  @writer.shutdown(wait)
341
341
  @reader&.close
342
+ @archive&.close
342
343
  end
343
344
  end
344
345
 
data/rubydb.gemspec CHANGED
@@ -42,8 +42,18 @@ Gem::Specification.new do |spec|
42
42
  # Specify which files should be added to the gem when it is released.
43
43
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
44
44
  spec.files = Dir.chdir(__dir__) do
45
- `git ls-files -z`.split("\x0").reject do |f|
46
- f.match(%r{^(test|spec|features|benchmarks|fuzz|chaos|examples|adapters/python)/})
45
+ tracked_files = `git ls-files -z`.split("\x0")
46
+ accelerator_files = Dir[
47
+ "accelerator/bin/rubydb-accelerator-*",
48
+ "accelerator/bin/SHA256SUMS",
49
+ "accelerator/cmd/**/*.go",
50
+ "accelerator/internal/**/*.go",
51
+ "accelerator/go.mod"
52
+ ]
53
+ runtime_files = Dir["lib/rubydb/accelerator.rb", "lib/rubydb/accelerator/**/*.rb", "lib/rubydb/cli/commands/accelerator.rb", "lib/rubydb/storage/snapshot_reader.rb"]
54
+ physical_execution_files = Dir["lib/rubydb/execution/physical_plan.rb", "lib/rubydb/execution/cost_model.rb", "lib/rubydb/execution/operator_selection.rb", "lib/rubydb/execution/accelerator_dispatch.rb"]
55
+ (tracked_files + accelerator_files + runtime_files + physical_execution_files).uniq.select { |f| File.file?(f) }.reject do |f|
56
+ f.match?(%r{^(test|spec|features|benchmarks|fuzz|chaos|examples|adapters/python)/}) || f.end_with?("_test.go")
47
57
  end
48
58
  end
49
59
  spec.bindir = "exe"