xitdb 0.1.0

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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE +21 -0
  4. data/README.md +131 -0
  5. data/docs/benchmarks.md +135 -0
  6. data/docs/core.md +123 -0
  7. data/docs/portable.md +99 -0
  8. data/docs/releasing.md +37 -0
  9. data/docs/ruby-profile-v1.md +226 -0
  10. data/docs/ruby.md +61 -0
  11. data/examples/indexed_users.rb +23 -0
  12. data/examples/portable.rb +62 -0
  13. data/examples/ruby.rb +56 -0
  14. data/lib/xitdb/context.rb +360 -0
  15. data/lib/xitdb/conversion.rb +222 -0
  16. data/lib/xitdb/core/array_list.rb +282 -0
  17. data/lib/xitdb/core/b_tree.rb +546 -0
  18. data/lib/xitdb/core/bytes.rb +181 -0
  19. data/lib/xitdb/core/compaction.rb +172 -0
  20. data/lib/xitdb/core/crc64.rb +22 -0
  21. data/lib/xitdb/core/cursor.rb +201 -0
  22. data/lib/xitdb/core/database.rb +359 -0
  23. data/lib/xitdb/core/file_store.rb +194 -0
  24. data/lib/xitdb/core/format.rb +148 -0
  25. data/lib/xitdb/core/hash_map.rb +371 -0
  26. data/lib/xitdb/core/hasher.rb +49 -0
  27. data/lib/xitdb/core/linked_array_list.rb +85 -0
  28. data/lib/xitdb/core/memory_store.rb +277 -0
  29. data/lib/xitdb/core/offset_map.rb +85 -0
  30. data/lib/xitdb/core/replication.rb +242 -0
  31. data/lib/xitdb/core/sorted_map.rb +361 -0
  32. data/lib/xitdb/core/transaction.rb +159 -0
  33. data/lib/xitdb/core.rb +17 -0
  34. data/lib/xitdb/database.rb +332 -0
  35. data/lib/xitdb/declarations.rb +73 -0
  36. data/lib/xitdb/errors.rb +84 -0
  37. data/lib/xitdb/history.rb +58 -0
  38. data/lib/xitdb/input_guard.rb +39 -0
  39. data/lib/xitdb/profiles/portable.rb +407 -0
  40. data/lib/xitdb/profiles/ruby.rb +173 -0
  41. data/lib/xitdb/revision.rb +42 -0
  42. data/lib/xitdb/ruby/codec.rb +123 -0
  43. data/lib/xitdb/ruby/containers.rb +495 -0
  44. data/lib/xitdb/ruby/key_encoding.rb +60 -0
  45. data/lib/xitdb/ruby/ordered_keys.rb +87 -0
  46. data/lib/xitdb/ruby/scalars.rb +296 -0
  47. data/lib/xitdb/ruby/wire.rb +146 -0
  48. data/lib/xitdb/ruby.rb +46 -0
  49. data/lib/xitdb/snapshot.rb +13 -0
  50. data/lib/xitdb/streams.rb +148 -0
  51. data/lib/xitdb/value_walker.rb +192 -0
  52. data/lib/xitdb/version.rb +5 -0
  53. data/lib/xitdb/views/array.rb +303 -0
  54. data/lib/xitdb/views/base.rb +271 -0
  55. data/lib/xitdb/views/linked_array.rb +18 -0
  56. data/lib/xitdb/views/map.rb +162 -0
  57. data/lib/xitdb/views/set.rb +41 -0
  58. data/lib/xitdb/views/sorted.rb +77 -0
  59. data/lib/xitdb.rb +20 -0
  60. data/sig/base.rbs +76 -0
  61. data/sig/core.rbs +697 -0
  62. data/sig/manifest.yaml +10 -0
  63. data/sig/native.rbs +118 -0
  64. data/sig/public.rbs +427 -0
  65. data/sig/ruby.rbs +337 -0
  66. metadata +137 -0
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "compaction"
5
+ require_relative "replication"
6
+
7
+ module XitDB
8
+ module Core
9
+ # Coordinates core storage, transactions, and committed roots.
10
+ class Database
11
+ include Replication
12
+
13
+ ROOT_OFFSET = 20
14
+ DATA_OFFSET = 37
15
+ TRAILER_SIZE = 25
16
+ MINIMUM_SIZE = 62
17
+
18
+ attr_reader :header
19
+
20
+ def self.open(path, readonly: false, **)
21
+ store = FileStore.open(path, readonly: readonly)
22
+ begin
23
+ database = new(store: store, **)
24
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
25
+ store.send(:close_unreturned)
26
+ raise
27
+ end
28
+ return database unless block_given?
29
+
30
+ begin
31
+ yield database
32
+ ensure
33
+ database.close
34
+ end
35
+ end
36
+
37
+ def self.memory(hash_size: 20, hash_id: "sha1", file_id: nil)
38
+ new(store: MemoryStore.new, hash_size: hash_size, hash_id: hash_id, file_id: file_id)
39
+ end
40
+
41
+ def self.copy_replica(to:, source_io:, length:)
42
+ Replication.copy_replica(to: to, source_io: source_io, length: length)
43
+ end
44
+
45
+ def initialize(store:, hash_size: nil, hash_id: nil, file_id: nil, lock_timeout: 5.0, initialize_empty: true,
46
+ require_empty: false)
47
+ @store = store
48
+ @lock_timeout = lock_timeout
49
+ @needs_reopen = false
50
+ @transaction = nil
51
+ initialize_file = lambda do
52
+ prepare_file(hash_size, hash_id, file_id, initialize_empty, require_empty)
53
+ end
54
+ if store.readonly?
55
+ initialize_file.call
56
+ else
57
+ store.with_exclusive_lock(timeout: lock_timeout, &initialize_file)
58
+ end
59
+ rescue EOFError => e
60
+ raise CorruptionError, e.message
61
+ end
62
+
63
+ def close
64
+ @store.close
65
+ end
66
+
67
+ def closed?
68
+ @store.closed?
69
+ end
70
+
71
+ def with_operation(&)
72
+ @store.with_operation(&)
73
+ end
74
+
75
+ def storage_identity
76
+ check_open!
77
+ @store.storage_identity
78
+ end
79
+
80
+ def needs_reopen?
81
+ @needs_reopen
82
+ end
83
+
84
+ def check_open!
85
+ raise ClosedError, "database is closed" if closed?
86
+ end
87
+
88
+ def committed_size
89
+ committed_state.first
90
+ end
91
+
92
+ def compact(to:)
93
+ with_operation do
94
+ # @type var offsets_for_cleanup: OffsetMap?
95
+ offsets_for_cleanup = nil
96
+ # @type var target_for_cleanup: Database?
97
+ target_for_cleanup = nil
98
+ source = root_cursor
99
+ slot = source.slot
100
+ history = slot.tag == :array_list
101
+ slot = ArrayList.new(source).get(-1) || Slot.new if history
102
+ store = FileStore.open(to)
103
+ begin
104
+ raise ArgumentError, "compaction target is the source file" if store.storage_identity == storage_identity
105
+
106
+ file_id = random_id
107
+ file_id = random_id while file_id == header.file_id
108
+ target = Database.new(store: store, hash_id: header.hash_id, hash_size: header.hash_size, file_id: file_id,
109
+ lock_timeout: @lock_timeout, require_empty: true)
110
+ target_for_cleanup = target
111
+ offsets = OffsetMap.new
112
+ offsets_for_cleanup = offsets
113
+ unless slot.empty?
114
+ copy_compacted_root(target, store, file_id, source, slot, history, offsets)
115
+ published = true
116
+ end
117
+ offsets.close
118
+ offsets_for_cleanup = nil
119
+ completed = true
120
+ target
121
+ ensure
122
+ cleanup_compaction(store, target_for_cleanup, offsets_for_cleanup, published) unless completed
123
+ end
124
+ end
125
+ end
126
+
127
+ def root_cursor
128
+ size, slot = committed_state
129
+ Cursor.new(database: self, slot: slot, bound: size)
130
+ end
131
+
132
+ def transaction
133
+ check_open!
134
+ raise TransactionStateError, "database requires reopening" if @needs_reopen
135
+
136
+ callback_completed = false
137
+ # @type var callback_error: Exception?
138
+ callback_error = nil
139
+ # @type var outcome: :not_committed | :committed | :unknown
140
+ outcome = :not_committed
141
+ @store.with_exclusive_lock(timeout: @lock_timeout, on_release_error: ->(_error) { @needs_reopen = true }) do
142
+ raise TransactionStateError, "database requires reopening" if @needs_reopen
143
+
144
+ size, slot = committed_state
145
+ @store.truncate(size) if @store.length > size
146
+ transaction = Transaction.new(self, @store, slot)
147
+ @transaction = transaction
148
+ begin
149
+ begin
150
+ yield transaction.root
151
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
152
+ callback_error = e
153
+ raise
154
+ end
155
+ callback_completed = true
156
+ unless transaction.cancelled?
157
+ publish(transaction.root.slot, random_id)
158
+ outcome = :committed
159
+ end
160
+ ensure
161
+ finish_transaction(transaction, size, outcome, callback_completed, $!) # rubocop:disable Style/SpecialGlobalVars -- Preserve the pending exception during cleanup.
162
+ end
163
+ root_cursor
164
+ end
165
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
166
+ handle_transaction_error(e, callback_error, callback_completed, outcome || :not_committed)
167
+ end
168
+
169
+ def handle_transaction_error(error, callback_error, callback_completed, outcome)
170
+ if callback_error && !error.equal?(callback_error)
171
+ @needs_reopen = true
172
+ raise callback_error, cause: callback_error.cause
173
+ end
174
+ raise error if error.is_a?(CommitError) || !callback_completed
175
+
176
+ @needs_reopen = true
177
+ raise CommitError.new(error.message, outcome: outcome), cause: error
178
+ end
179
+ private :handle_transaction_error
180
+
181
+ def bytes(data, format_tag: nil)
182
+ transaction = active_transaction
183
+ value = ByteValue.new(data, format_tag: format_tag)
184
+ Bytes.append(transaction, size: value.bytes.bytesize, format_tag: value.format_tag) do |position, length|
185
+ Format.slice(value.bytes, position, length)
186
+ end
187
+ end
188
+
189
+ def read_bytes(slot, bound: nil)
190
+ check_open!
191
+ bound ||= committed_size
192
+ source = Bytes.open(self, slot, bound: bound)
193
+ ByteValue.new(source.read(0, source.size), format_tag: source.format_tag)
194
+ end
195
+
196
+ def read_at(offset, size, bound:)
197
+ check_open!
198
+ check_range!(offset, size, bound)
199
+ @store.read_at(offset, size)
200
+ rescue EOFError => e
201
+ raise CorruptionError, e.message
202
+ end
203
+
204
+ private
205
+
206
+ def copy_compacted_root(target, store, file_id, source, slot, history, offsets)
207
+ target.transaction do |root|
208
+ current = Header.parse(store.read_at(0, Header::SIZE))
209
+ unless current.file_id == file_id && root.bound == MINIMUM_SIZE && root.slot.empty?
210
+ raise ArgumentError, "compaction target changed before copying"
211
+ end
212
+
213
+ transaction = root.transaction
214
+ raise TransactionStateError, "missing compaction transaction" unless transaction
215
+
216
+ copier = Compaction.new(self, source, transaction, offsets)
217
+ if history
218
+ ArrayList.new(root).append(copier.copy(slot))
219
+ else
220
+ root.replace(copier.copy(slot))
221
+ end
222
+ end
223
+ end
224
+
225
+ def cleanup_compaction(store, target, offsets, published)
226
+ begin
227
+ offsets&.close
228
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
229
+ nil
230
+ end
231
+ target&.restore_empty_target unless published
232
+ store.send(:close_unreturned)
233
+ end
234
+
235
+ def finish_transaction(transaction, size, outcome, callback_completed, pending_error)
236
+ transaction.expire!
237
+ @transaction = nil
238
+ # Cleanup must preserve caller exceptions and nonlocal exits.
239
+ return unless outcome == :not_committed && !@needs_reopen
240
+
241
+ begin
242
+ @store.truncate(size)
243
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
244
+ @needs_reopen = true
245
+ if callback_completed && pending_error.nil?
246
+ raise CommitError.new(e.message, outcome: :not_committed), cause: e
247
+ end
248
+ end
249
+ end
250
+
251
+ def prepare_file(hash_size, hash_id, file_id, initialize_empty, require_empty)
252
+ raise ArgumentError, "compaction target must be empty" if require_empty && !@store.empty?
253
+
254
+ if @store.empty?
255
+ raise CorruptionError, "empty read-only database" if @store.readonly?
256
+ raise CorruptionError, "existing empty database" unless initialize_empty
257
+
258
+ initialize_empty_file(hash_size, hash_id, file_id, require_empty)
259
+ else
260
+ @header = Header.parse(@store.read_at(0, Header::SIZE))
261
+ check_header(:hash_size, hash_size, @header.hash_size)
262
+ check_header(:hash_id, hash_id, @header.hash_id)
263
+ check_header(:file_id, file_id, @header.file_id)
264
+ committed_state
265
+ end
266
+ end
267
+
268
+ def initialize_empty_file(hash_size, hash_id, file_id, require_empty)
269
+ @header = Header.new(hash_size: hash_size || 20, hash_id: hash_id || "sha1", file_id: file_id || random_id)
270
+ @store.write_at(0, @header.to_bytes + ("\0".b * 17))
271
+ publish(Slot.new, 0)
272
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
273
+ reset_failed_initialization(require_empty)
274
+ raise
275
+ end
276
+
277
+ def reset_failed_initialization(require_empty)
278
+ # The exclusive initialization lock still owns this empty target.
279
+ @store.truncate(0) if require_empty && !@needs_reopen
280
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
281
+ nil
282
+ end
283
+
284
+ def check_header(name, expected, actual)
285
+ return if expected.nil? || expected == actual
286
+
287
+ raise ProfileMismatchError, "#{name} does not match the database header"
288
+ end
289
+
290
+ def close_unreturned
291
+ @store.send(:close_unreturned)
292
+ end
293
+
294
+ def restore_empty_target
295
+ return if needs_reopen?
296
+
297
+ @store.with_exclusive_lock(timeout: @lock_timeout) do
298
+ current = Header.parse(@store.read_at(0, Header::SIZE))
299
+ next unless current.file_id == header.file_id
300
+
301
+ size, slot = committed_state
302
+ @store.truncate(0) if size == MINIMUM_SIZE && slot.empty?
303
+ end
304
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
305
+ # Cleanup must preserve the original failure or nonlocal exit. A new
306
+ # generation or committed root belongs to its writer and is retained.
307
+ nil
308
+ end
309
+
310
+ protected :restore_empty_target
311
+
312
+ def active_transaction
313
+ transaction = @transaction
314
+ raise TransactionStateError, "allocation requires a transaction" unless transaction
315
+
316
+ transaction.check_active!
317
+ transaction
318
+ end
319
+
320
+ def check_range!(offset, size, bound)
321
+ unless offset.is_a?(Integer) && size.is_a?(Integer) && bound.is_a?(Integer) &&
322
+ offset >= DATA_OFFSET && size >= 0 && offset + size <= bound
323
+ raise CorruptionError, "object exceeds its read boundary"
324
+ end
325
+ end
326
+
327
+ def committed_state
328
+ check_open!
329
+ size = Format.unsigned(@store.read_at(ROOT_OFFSET + Slot::SIZE, 8))
330
+ raise CorruptionError, "invalid committed file size" if size < MINIMUM_SIZE || size > @store.length
331
+
332
+ slot, = Replication.trailer(@store, header, size, size)
333
+ [size, slot]
334
+ rescue EOFError => e
335
+ raise CorruptionError, e.message
336
+ end
337
+
338
+ def publish(slot, commit_id)
339
+ # @type var outcome: :not_committed | :committed | :unknown
340
+ outcome = :not_committed
341
+ position = @store.length
342
+ body = slot.to_bytes + [commit_id].pack("Q>")
343
+ checksum = CRC64.call([header.file_id, position].pack("Q>Q>") + body)
344
+ @store.append(body + [checksum].pack("Q>"))
345
+ @store.sync
346
+ outcome = :unknown
347
+ @store.write_at(ROOT_OFFSET, slot.to_bytes + [position + TRAILER_SIZE].pack("Q>"))
348
+ @store.sync
349
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
350
+ @needs_reopen = true if outcome == :unknown
351
+ raise CommitError.new(e.message, outcome: outcome || :not_committed), cause: e
352
+ end
353
+
354
+ def random_id
355
+ Format.unsigned(SecureRandom.random_bytes(8))
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ module Core
5
+ # A synchronized file store with bounded write buffering.
6
+ class FileStore
7
+ include Store
8
+
9
+ attr_reader :storage_identity
10
+
11
+ WRITERS = {} # rubocop:disable Style/MutableConstant -- Writer registry updates are protected by WRITERS_MUTEX.
12
+ WRITERS_MUTEX = Mutex.new
13
+ private_constant :WRITERS, :WRITERS_MUTEX
14
+
15
+ def self.open(path, readonly: false, buffer_limit: 2 * 1024 * 1024, create: true, exclusive: false)
16
+ raise TypeError, "buffer_limit must be an Integer" unless buffer_limit.is_a?(Integer)
17
+ raise ArgumentError, "buffer_limit must be positive" unless buffer_limit.positive?
18
+ raise ArgumentError, "exclusive opening requires writable creation" if exclusive && (readonly || !create)
19
+
20
+ file = open_file(path, readonly, create, exclusive)
21
+ begin
22
+ directory = File.dirname(File.expand_path(path)) unless readonly
23
+ new(file, readonly, buffer_limit, sync_directory: directory)
24
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
25
+ file.close
26
+ raise
27
+ end
28
+ end
29
+
30
+ def self.open_file(path, readonly, create, exclusive)
31
+ flags = readonly ? File::RDONLY : File::RDWR
32
+ return File.open(path, flags) unless create && !readonly
33
+
34
+ begin
35
+ File.open(path, flags | File::CREAT | File::EXCL, 0o666)
36
+ rescue Errno::EEXIST
37
+ raise if exclusive
38
+
39
+ File.open(path, flags)
40
+ end
41
+ end
42
+ private_class_method :open_file
43
+
44
+ def initialize(file, readonly, buffer_limit, sync_directory: nil)
45
+ @file = file.binmode
46
+ @sync_directory = sync_directory
47
+ @readonly = readonly
48
+ @buffer_limit = buffer_limit
49
+ @pending = "".b
50
+ @pending_offset = 0
51
+ stat = @file.stat
52
+ @identity = [Process.pid, stat.dev, stat.ino].freeze
53
+ @storage_identity = @identity
54
+ writer = WRITERS_MUTEX.synchronize do
55
+ entry = WRITERS[@identity] ||= [Mutex.new, 0]
56
+ entry[1] += 1
57
+ entry[0]
58
+ end
59
+ initialize_state(writer)
60
+ end
61
+
62
+ def readonly?
63
+ @readonly
64
+ end
65
+
66
+ private
67
+
68
+ def store_length
69
+ disk_length = @file.stat.size
70
+ @pending.empty? ? disk_length : [disk_length, @pending_offset + @pending.bytesize].max
71
+ end
72
+
73
+ def read_bytes(offset, length)
74
+ return read_file(offset, length) if @pending.empty?
75
+
76
+ result = "".b
77
+ finish = offset + length
78
+ if offset < @pending_offset
79
+ count = [finish, @pending_offset].min - offset
80
+ disk_count = (@file.stat.size - offset).clamp(0, count)
81
+ result << read_file(offset, disk_count)
82
+ result << ("\0".b * (count - disk_count))
83
+ offset += count
84
+ end
85
+ if offset < finish && offset < @pending_offset + @pending.bytesize
86
+ count = [finish - offset, @pending_offset + @pending.bytesize - offset].min
87
+ result << Format.slice(@pending, offset - @pending_offset, count)
88
+ offset += count
89
+ end
90
+ result << read_file(offset, finish - offset) if offset < finish
91
+ result
92
+ end
93
+
94
+ def read_file(offset, length)
95
+ result = "".b
96
+ while result.bytesize < length
97
+ bytes = retry_interrupted { @file.pread(length - result.bytesize, offset + result.bytesize) }
98
+ raise EOFError, "read exceeds file length" if bytes.empty?
99
+
100
+ result << bytes
101
+ end
102
+ result
103
+ end
104
+
105
+ def write_bytes(offset, bytes)
106
+ if bytes.bytesize > @buffer_limit
107
+ flush_bytes
108
+ write_file(offset, bytes)
109
+ return
110
+ end
111
+
112
+ unless @pending.empty? || (offset.between?(@pending_offset, @pending_offset + @pending.bytesize) &&
113
+ offset + bytes.bytesize <= @pending_offset + @buffer_limit)
114
+ flush_bytes
115
+ end
116
+ @pending_offset = offset if @pending.empty?
117
+ @pending[offset - @pending_offset, bytes.bytesize] = bytes
118
+ end
119
+
120
+ def write_file(offset, bytes)
121
+ written = 0
122
+ while written < bytes.bytesize
123
+ count = retry_interrupted { @file.pwrite(Format.slice(bytes, written..), offset + written) }
124
+ raise IOError, "file write made no progress" unless count.positive?
125
+
126
+ written += count
127
+ end
128
+ end
129
+
130
+ def truncate_bytes(length)
131
+ retry_interrupted { @file.truncate(length) }
132
+ if length <= @pending_offset
133
+ @pending.clear
134
+ elsif length < @pending_offset + @pending.bytesize
135
+ @pending = Format.slice(@pending, 0, length - @pending_offset)
136
+ end
137
+ end
138
+
139
+ def flush_bytes
140
+ return if @pending.empty?
141
+
142
+ write_file(@pending_offset, @pending)
143
+ @pending.clear
144
+ nil
145
+ end
146
+
147
+ def sync_bytes
148
+ retry_interrupted { @file.fsync }
149
+ sync_directory = @sync_directory
150
+ if sync_directory
151
+ File.open(sync_directory, File::RDONLY) do |directory|
152
+ retry_interrupted { directory.fsync }
153
+ end
154
+ @sync_directory = nil
155
+ end
156
+ nil
157
+ end
158
+
159
+ def close_bytes
160
+ @file.close
161
+ WRITERS_MUTEX.synchronize do
162
+ entry = WRITERS.fetch(@identity)
163
+ entry[1] -= 1
164
+ WRITERS.delete(@identity) if entry[1].zero?
165
+ end
166
+ end
167
+
168
+ def lock_writer(deadline)
169
+ raise TransactionStateError, "writer lock for this file is already held by this thread" if @writer_mutex.owned?
170
+
171
+ super
172
+ acquired = false
173
+ begin
174
+ wait_for_writer(deadline) until retry_interrupted { @file.flock(File::LOCK_EX | File::LOCK_NB) }
175
+ acquired = true
176
+ ensure
177
+ @writer_mutex.unlock unless acquired
178
+ end
179
+ end
180
+
181
+ def unlock_writer
182
+ retry_interrupted { @file.flock(File::LOCK_UN) }
183
+ ensure
184
+ super
185
+ end
186
+
187
+ def retry_interrupted
188
+ yield
189
+ rescue Errno::EINTR
190
+ retry
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ module Core
5
+ # Validates and decodes the binary storage format.
6
+ module Format
7
+ UINT64_MAX = (1 << 64) - 1
8
+
9
+ def self.integer(value, range)
10
+ raise TypeError, "expected Integer, got #{value.class}" unless value.is_a?(Integer)
11
+ raise RangeError, "integer outside #{range}" unless range.cover?(value)
12
+
13
+ value
14
+ end
15
+
16
+ def self.width(bytes, width)
17
+ raise TypeError, "expected String, got #{bytes.class}" unless bytes.is_a?(String)
18
+ raise CorruptionError, "expected #{width} bytes, got #{bytes.bytesize}" unless bytes.bytesize == width
19
+ end
20
+
21
+ def self.slice(bytes, offset, length = nil)
22
+ result = if offset.is_a?(Range)
23
+ bytes.byteslice(offset)
24
+ else
25
+ bytes.byteslice(offset, length || 1)
26
+ end
27
+ raise CorruptionError, "byte slice exceeds payload" unless result
28
+
29
+ result
30
+ end
31
+
32
+ def self.unsigned(bytes)
33
+ value = bytes.unpack1("Q>")
34
+ raise CorruptionError, "invalid unsigned integer" unless value.is_a?(Integer)
35
+
36
+ value
37
+ end
38
+
39
+ def self.integers(bytes, format)
40
+ bytes.unpack(format).map do |value|
41
+ raise CorruptionError, "invalid integer payload" unless value.is_a?(Integer)
42
+
43
+ value
44
+ end
45
+ end
46
+
47
+ def self.pair(bytes, format)
48
+ values = integers(bytes, format)
49
+ [values.fetch(0), values.fetch(1)]
50
+ end
51
+ end
52
+
53
+ # The immutable database format header.
54
+ class Header
55
+ SIZE = 20
56
+ VERSION = 1
57
+
58
+ attr_reader :file_id, :hash_id, :hash_size
59
+
60
+ def initialize(file_id:, hash_id:, hash_size:, padding: 0)
61
+ @file_id = Format.integer(file_id, 0..Format::UINT64_MAX)
62
+ @hash_size = Format.integer(hash_size, 0..65_535)
63
+ raise TypeError, "hash_id must be a String" unless hash_id.is_a?(String)
64
+ raise ArgumentError, "hash_id must contain four bytes" unless hash_id.bytesize == 4
65
+
66
+ @hash_id = hash_id.b.freeze
67
+ @padding = Format.integer(padding, 0..255)
68
+ freeze
69
+ end
70
+
71
+ def self.parse(bytes)
72
+ Format.width(bytes, SIZE)
73
+ magic = Format.slice(bytes, 0, 3)
74
+ padding = bytes.getbyte(3) || raise(CorruptionError, "missing header padding")
75
+ version, hash_size = Format.pair(Format.slice(bytes, 4, 4), "nn")
76
+ hash_id = Format.slice(bytes, 8, 4)
77
+ file_id = Format.unsigned(Format.slice(bytes, 12, 8))
78
+ raise CorruptionError, "invalid xitdb magic number" unless magic == "xit"
79
+ raise FormatVersionError, "unsupported xitdb format version #{version}" unless version == VERSION
80
+
81
+ new(file_id: file_id, hash_id: hash_id, hash_size: hash_size, padding: padding)
82
+ end
83
+
84
+ def to_bytes
85
+ ["xit", @padding, VERSION, hash_size, hash_id, file_id].pack("a3Cnn a4Q>")
86
+ end
87
+ end
88
+
89
+ # An immutable tagged value or record reference.
90
+ class Slot
91
+ SIZE = 9
92
+ TAGS = %i[none index array_list linked_array_list hash_map kv_pair bytes short_bytes uint int float
93
+ hash_set counted_hash_map counted_hash_set sorted_map sorted_set sorted_kv_pair].freeze
94
+
95
+ attr_reader :tag, :value, :full
96
+
97
+ def initialize(tag: :none, value: 0, full: false)
98
+ code = tag.is_a?(Symbol) ? TAGS.index(tag) : tag
99
+ raise ArgumentError, "invalid slot tag: #{tag.inspect}" unless code.is_a?(Integer) && (0..127).cover?(code)
100
+ raise TypeError, "full must be true or false" unless full.equal?(true) || full.equal?(false)
101
+
102
+ @tag = TAGS[code] || code
103
+ @value = Format.integer(value, 0..Format::UINT64_MAX)
104
+ @full = full
105
+ freeze
106
+ end
107
+
108
+ def self.parse(bytes)
109
+ Format.width(bytes, SIZE)
110
+ flags = bytes.getbyte(0) || raise(CorruptionError, "missing slot flags")
111
+ value = Format.unsigned(Format.slice(bytes, 1, 8))
112
+ new(tag: flags & 0x7f, value: value, full: flags.anybits?(0x80))
113
+ end
114
+
115
+ def self.int(value)
116
+ Format.integer(value, -(1 << 63)..((1 << 63) - 1))
117
+ new(tag: :int, value: value & Format::UINT64_MAX)
118
+ end
119
+
120
+ def self.uint(value)
121
+ new(tag: :uint, value: value)
122
+ end
123
+
124
+ def self.float(value)
125
+ raise TypeError, "expected Float, got #{value.class}" unless value.is_a?(Float)
126
+
127
+ new(tag: :float, value: Format.unsigned([value].pack("G")))
128
+ end
129
+
130
+ def self.null
131
+ new(full: true)
132
+ end
133
+
134
+ def empty?
135
+ tag == :none && !full
136
+ end
137
+
138
+ def ==(other)
139
+ other.is_a?(Slot) && tag == other.tag && full == other.full && value == other.value
140
+ end
141
+
142
+ def to_bytes
143
+ code = tag.is_a?(Symbol) ? TAGS.index(tag) : tag
144
+ [code | (full ? 0x80 : 0), value].pack("CQ>")
145
+ end
146
+ end
147
+ end
148
+ end