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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/docs/benchmarks.md +135 -0
- data/docs/core.md +123 -0
- data/docs/portable.md +99 -0
- data/docs/releasing.md +37 -0
- data/docs/ruby-profile-v1.md +226 -0
- data/docs/ruby.md +61 -0
- data/examples/indexed_users.rb +23 -0
- data/examples/portable.rb +62 -0
- data/examples/ruby.rb +56 -0
- data/lib/xitdb/context.rb +360 -0
- data/lib/xitdb/conversion.rb +222 -0
- data/lib/xitdb/core/array_list.rb +282 -0
- data/lib/xitdb/core/b_tree.rb +546 -0
- data/lib/xitdb/core/bytes.rb +181 -0
- data/lib/xitdb/core/compaction.rb +172 -0
- data/lib/xitdb/core/crc64.rb +22 -0
- data/lib/xitdb/core/cursor.rb +201 -0
- data/lib/xitdb/core/database.rb +359 -0
- data/lib/xitdb/core/file_store.rb +194 -0
- data/lib/xitdb/core/format.rb +148 -0
- data/lib/xitdb/core/hash_map.rb +371 -0
- data/lib/xitdb/core/hasher.rb +49 -0
- data/lib/xitdb/core/linked_array_list.rb +85 -0
- data/lib/xitdb/core/memory_store.rb +277 -0
- data/lib/xitdb/core/offset_map.rb +85 -0
- data/lib/xitdb/core/replication.rb +242 -0
- data/lib/xitdb/core/sorted_map.rb +361 -0
- data/lib/xitdb/core/transaction.rb +159 -0
- data/lib/xitdb/core.rb +17 -0
- data/lib/xitdb/database.rb +332 -0
- data/lib/xitdb/declarations.rb +73 -0
- data/lib/xitdb/errors.rb +84 -0
- data/lib/xitdb/history.rb +58 -0
- data/lib/xitdb/input_guard.rb +39 -0
- data/lib/xitdb/profiles/portable.rb +407 -0
- data/lib/xitdb/profiles/ruby.rb +173 -0
- data/lib/xitdb/revision.rb +42 -0
- data/lib/xitdb/ruby/codec.rb +123 -0
- data/lib/xitdb/ruby/containers.rb +495 -0
- data/lib/xitdb/ruby/key_encoding.rb +60 -0
- data/lib/xitdb/ruby/ordered_keys.rb +87 -0
- data/lib/xitdb/ruby/scalars.rb +296 -0
- data/lib/xitdb/ruby/wire.rb +146 -0
- data/lib/xitdb/ruby.rb +46 -0
- data/lib/xitdb/snapshot.rb +13 -0
- data/lib/xitdb/streams.rb +148 -0
- data/lib/xitdb/value_walker.rb +192 -0
- data/lib/xitdb/version.rb +5 -0
- data/lib/xitdb/views/array.rb +303 -0
- data/lib/xitdb/views/base.rb +271 -0
- data/lib/xitdb/views/linked_array.rb +18 -0
- data/lib/xitdb/views/map.rb +162 -0
- data/lib/xitdb/views/set.rb +41 -0
- data/lib/xitdb/views/sorted.rb +77 -0
- data/lib/xitdb.rb +20 -0
- data/sig/base.rbs +76 -0
- data/sig/core.rbs +697 -0
- data/sig/manifest.yaml +10 -0
- data/sig/native.rbs +118 -0
- data/sig/public.rbs +427 -0
- data/sig/ruby.rbs +337 -0
- 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
|