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,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module XitDB
|
|
6
|
+
module Core
|
|
7
|
+
# Coordinates store lifetime and exclusive writers.
|
|
8
|
+
module Store
|
|
9
|
+
# Only for owned stores whose construction or maintenance failed.
|
|
10
|
+
def close_unreturned
|
|
11
|
+
close
|
|
12
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
|
|
13
|
+
begin
|
|
14
|
+
close(discard: true)
|
|
15
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
private :close_unreturned
|
|
20
|
+
|
|
21
|
+
def read_at(offset, length)
|
|
22
|
+
synchronize do
|
|
23
|
+
position(offset)
|
|
24
|
+
position(length)
|
|
25
|
+
next "".b if length.zero?
|
|
26
|
+
raise EOFError, "read exceeds store length" if offset + length > store_length
|
|
27
|
+
|
|
28
|
+
read_bytes(offset, length)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_at(offset, bytes)
|
|
33
|
+
synchronize do
|
|
34
|
+
writable!
|
|
35
|
+
position(offset)
|
|
36
|
+
string(bytes)
|
|
37
|
+
write_bytes(offset, bytes.b) unless bytes.empty?
|
|
38
|
+
bytes.bytesize
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append(bytes)
|
|
43
|
+
synchronize do
|
|
44
|
+
writable!
|
|
45
|
+
offset = store_length
|
|
46
|
+
write_at(offset, bytes)
|
|
47
|
+
offset
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def length
|
|
52
|
+
synchronize { store_length }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty?
|
|
56
|
+
length.zero?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def truncate(length)
|
|
60
|
+
synchronize do
|
|
61
|
+
writable!
|
|
62
|
+
position(length)
|
|
63
|
+
truncate_bytes(length)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def flush
|
|
69
|
+
synchronize { flush_bytes }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sync
|
|
73
|
+
synchronize do
|
|
74
|
+
flush_bytes
|
|
75
|
+
sync_bytes
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Pin the handle across all storage calls in one logical operation.
|
|
80
|
+
def with_operation
|
|
81
|
+
thread = Thread.current
|
|
82
|
+
synchronize { @operation_users[thread] = @operation_users.fetch(thread, 0) + 1 }
|
|
83
|
+
begin
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
@state.synchronize do
|
|
87
|
+
@operation_users[thread] -= 1
|
|
88
|
+
@operation_users.delete(thread) if @operation_users[thread].zero?
|
|
89
|
+
@idle.broadcast
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def with_exclusive_lock(timeout: 5, on_release_error: nil)
|
|
95
|
+
raise TypeError, "timeout must be a real Numeric" unless timeout.is_a?(Numeric) && timeout.real?
|
|
96
|
+
raise ArgumentError, "timeout must be finite and nonnegative" unless timeout.finite? && timeout >= 0
|
|
97
|
+
|
|
98
|
+
deadline = monotonic_time + timeout
|
|
99
|
+
synchronize do
|
|
100
|
+
writable!
|
|
101
|
+
raise TransactionStateError, "writer lock is already held by this thread" if @lock_owner == Thread.current
|
|
102
|
+
|
|
103
|
+
@lock_users += 1
|
|
104
|
+
end
|
|
105
|
+
locked = false
|
|
106
|
+
completed = false
|
|
107
|
+
begin
|
|
108
|
+
lock_writer(deadline)
|
|
109
|
+
locked = true
|
|
110
|
+
@state.synchronize do
|
|
111
|
+
writable!
|
|
112
|
+
@lock_owner = Thread.current
|
|
113
|
+
end
|
|
114
|
+
result = yield
|
|
115
|
+
completed = true
|
|
116
|
+
result
|
|
117
|
+
ensure
|
|
118
|
+
begin
|
|
119
|
+
release_writer(locked, completed, on_release_error)
|
|
120
|
+
ensure
|
|
121
|
+
@state.synchronize do
|
|
122
|
+
@lock_users -= 1
|
|
123
|
+
@idle.broadcast
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def close(discard: false)
|
|
130
|
+
@state.synchronize do
|
|
131
|
+
return if @closed
|
|
132
|
+
raise TransactionStateError, "cannot close inside the writer block" if @lock_owner == Thread.current
|
|
133
|
+
if @operation_users.key?(Thread.current)
|
|
134
|
+
raise TransactionStateError, "cannot close inside an active operation"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@closing = true
|
|
138
|
+
begin
|
|
139
|
+
@idle.wait until @lock_users.zero? && @operation_users.empty?
|
|
140
|
+
return if @closed
|
|
141
|
+
|
|
142
|
+
flush_bytes unless discard
|
|
143
|
+
close_bytes
|
|
144
|
+
@closed = true
|
|
145
|
+
ensure
|
|
146
|
+
@closing = false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def closed?
|
|
152
|
+
@state.synchronize { @closed }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def release_writer(locked, completed, on_release_error)
|
|
158
|
+
@state.synchronize do
|
|
159
|
+
@lock_owner = nil if locked
|
|
160
|
+
begin
|
|
161
|
+
unlock_writer if locked
|
|
162
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
|
|
163
|
+
@release_failed = true
|
|
164
|
+
on_release_error&.call(e)
|
|
165
|
+
# Raising here would replace a pending exception or nonlocal exit.
|
|
166
|
+
raise if completed
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def initialize_state(writer_mutex = Mutex.new)
|
|
172
|
+
@state = Monitor.new
|
|
173
|
+
@idle = @state.new_cond
|
|
174
|
+
@writer_mutex = writer_mutex
|
|
175
|
+
@lock_users = 0
|
|
176
|
+
@operation_users = {}
|
|
177
|
+
@lock_owner = nil
|
|
178
|
+
@closed = false
|
|
179
|
+
@closing = false
|
|
180
|
+
@release_failed = false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def synchronize
|
|
184
|
+
@state.synchronize do
|
|
185
|
+
if @closed || (@closing && @lock_owner != Thread.current && !@operation_users.key?(Thread.current))
|
|
186
|
+
raise ClosedError, "store is closed"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
yield
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def writable!
|
|
194
|
+
raise ReadOnlyError, "store is read-only" if readonly?
|
|
195
|
+
raise TransactionStateError, "writer lock release failed; reopen the store" if @release_failed
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def position(value)
|
|
199
|
+
raise TypeError, "offset and length must be Integers" unless value.is_a?(Integer)
|
|
200
|
+
raise ArgumentError, "offset and length must be nonnegative" if value.negative?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def string(bytes)
|
|
204
|
+
raise TypeError, "bytes must be a String" unless bytes.is_a?(String)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def monotonic_time
|
|
208
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wait_for_writer(deadline)
|
|
212
|
+
remaining = deadline - monotonic_time
|
|
213
|
+
raise BusyError, "writer lock timed out" unless remaining.positive?
|
|
214
|
+
|
|
215
|
+
sleep([remaining, 0.005].min)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def lock_writer(deadline)
|
|
219
|
+
wait_for_writer(deadline) until @writer_mutex.try_lock
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def unlock_writer
|
|
223
|
+
@writer_mutex.unlock
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
private_constant :Store
|
|
227
|
+
|
|
228
|
+
# A synchronized store backed by a binary string.
|
|
229
|
+
class MemoryStore
|
|
230
|
+
include Store
|
|
231
|
+
|
|
232
|
+
attr_reader :storage_identity
|
|
233
|
+
|
|
234
|
+
def initialize(bytes = "".b)
|
|
235
|
+
string(bytes)
|
|
236
|
+
@bytes = bytes.b
|
|
237
|
+
@storage_identity = Object.new.freeze
|
|
238
|
+
initialize_state
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def readonly?
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def store_length
|
|
248
|
+
@bytes.bytesize
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def read_bytes(offset, length)
|
|
252
|
+
Format.slice(@bytes, offset, length)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def write_bytes(offset, bytes)
|
|
256
|
+
@bytes << ("\0".b * (offset - store_length)) if offset > store_length
|
|
257
|
+
@bytes[offset, bytes.bytesize] = bytes
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def truncate_bytes(length)
|
|
261
|
+
if length < store_length
|
|
262
|
+
@bytes = Format.slice(@bytes, 0, length)
|
|
263
|
+
else
|
|
264
|
+
@bytes << ("\0".b * (length - store_length))
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def flush_bytes; end
|
|
269
|
+
|
|
270
|
+
def sync_bytes; end
|
|
271
|
+
|
|
272
|
+
def close_bytes
|
|
273
|
+
@bytes.clear
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module XitDB
|
|
6
|
+
module Core
|
|
7
|
+
# Tracks copied offsets and spills large maps to a temporary store.
|
|
8
|
+
class OffsetMap
|
|
9
|
+
CACHE_LIMIT = 16_384
|
|
10
|
+
|
|
11
|
+
attr_reader :peak_cache_size
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@cache = {}
|
|
15
|
+
@peak_cache_size = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(offset)
|
|
19
|
+
raise ClosedError, "offset map is closed" if @closed
|
|
20
|
+
|
|
21
|
+
map = @map
|
|
22
|
+
map ? map.get(key(offset))&.value : @cache[offset]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def store(offset, target)
|
|
26
|
+
raise ClosedError, "offset map is closed" if @closed
|
|
27
|
+
|
|
28
|
+
spill if !@map && @cache.size == CACHE_LIMIT && !@cache.key?(offset)
|
|
29
|
+
map = @map
|
|
30
|
+
if map
|
|
31
|
+
map.put(key(offset), key: Slot.new, value: Slot.uint(target))
|
|
32
|
+
else
|
|
33
|
+
@cache[offset] = target
|
|
34
|
+
@peak_cache_size = [@peak_cache_size, @cache.size].max
|
|
35
|
+
end
|
|
36
|
+
target
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def close
|
|
40
|
+
return if @cleanup_complete
|
|
41
|
+
|
|
42
|
+
@closed = true
|
|
43
|
+
@cache.clear
|
|
44
|
+
@transaction&.expire!
|
|
45
|
+
begin
|
|
46
|
+
@store&.close(discard: true)
|
|
47
|
+
ensure
|
|
48
|
+
@temporary&.close!
|
|
49
|
+
end
|
|
50
|
+
@cleanup_complete = true
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def key(offset)
|
|
57
|
+
[offset].pack("Q>")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def spill
|
|
61
|
+
temporary = Tempfile.new("xitdb-offset-map-")
|
|
62
|
+
@temporary = temporary
|
|
63
|
+
store = FileStore.open(temporary.path || raise(IOError, "temporary file has no path"))
|
|
64
|
+
@store = store
|
|
65
|
+
database = Database.new(store: store, hash_size: 8, hash_id: "offs")
|
|
66
|
+
@database = database
|
|
67
|
+
# The scratch file is private and never published. One transaction keeps
|
|
68
|
+
# every mapping available without a commit and fsync for each insertion.
|
|
69
|
+
transaction = Transaction.new(database, store, Slot.new)
|
|
70
|
+
@transaction = transaction
|
|
71
|
+
map = HashMap.new(transaction.root)
|
|
72
|
+
@map = map
|
|
73
|
+
@cache.each { |offset, target| map.put(key(offset), key: Slot.new, value: Slot.uint(target)) }
|
|
74
|
+
@cache.clear
|
|
75
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
|
|
76
|
+
begin
|
|
77
|
+
close
|
|
78
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module XitDB
|
|
4
|
+
module Core
|
|
5
|
+
ReplicaState = Data.define(:committed_size, :checksum)
|
|
6
|
+
|
|
7
|
+
# Transfers committed byte ranges while retaining commit outcome semantics.
|
|
8
|
+
module Replication
|
|
9
|
+
CHUNK_SIZE = 64 * 1024
|
|
10
|
+
|
|
11
|
+
def replica_state
|
|
12
|
+
with_operation do
|
|
13
|
+
size, = committed_state
|
|
14
|
+
_, checksum = Replication.trailer(@store, header, size, size)
|
|
15
|
+
ReplicaState.new(committed_size: size, checksum: checksum)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def replication_start(state)
|
|
20
|
+
raise TypeError, "expected ReplicaState" unless state.is_a?(ReplicaState)
|
|
21
|
+
|
|
22
|
+
Replication.position(state.committed_size)
|
|
23
|
+
Format.integer(state.checksum, 0..Format::UINT64_MAX)
|
|
24
|
+
with_operation do
|
|
25
|
+
size, = committed_state
|
|
26
|
+
next 0 if state.committed_size > size || state.committed_size < Database::MINIMUM_SIZE
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
_, checksum = Replication.trailer(@store, header, state.committed_size, size)
|
|
30
|
+
checksum == state.checksum ? state.committed_size : 0
|
|
31
|
+
rescue CorruptionError
|
|
32
|
+
0
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read_committed(start, finish, io)
|
|
38
|
+
Replication.position(start)
|
|
39
|
+
Replication.position(finish)
|
|
40
|
+
with_operation do
|
|
41
|
+
size, = committed_state
|
|
42
|
+
if start > finish || finish > size || (start.positive? && start < Database::DATA_OFFSET)
|
|
43
|
+
raise ArgumentError, "invalid committed byte range"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if start.zero?
|
|
47
|
+
slot, = Replication.trailer(@store, header, finish, size)
|
|
48
|
+
Replication.write_all(io, @store.read_at(0, Header::SIZE))
|
|
49
|
+
Replication.write_all(io, slot.to_bytes + [finish].pack("Q>"))
|
|
50
|
+
start = Database::DATA_OFFSET
|
|
51
|
+
end
|
|
52
|
+
while start < finish
|
|
53
|
+
chunk = @store.read_at(start, [finish - start, CHUNK_SIZE].min)
|
|
54
|
+
Replication.write_all(io, chunk)
|
|
55
|
+
start += chunk.bytesize
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply_replicated(start, io, length)
|
|
62
|
+
Replication.position(start)
|
|
63
|
+
Replication.position(length)
|
|
64
|
+
raise ArgumentError, "full replacement requires Database.copy_replica" if start.zero?
|
|
65
|
+
raise TransactionStateError, "database requires reopening" if @needs_reopen
|
|
66
|
+
|
|
67
|
+
# @type var outcome: :not_committed | :committed | :unknown
|
|
68
|
+
outcome = :not_committed
|
|
69
|
+
completed = false
|
|
70
|
+
@store.with_exclusive_lock(timeout: @lock_timeout, on_release_error: ->(_error) { @needs_reopen = true }) do
|
|
71
|
+
raise TransactionStateError, "database requires reopening" if @needs_reopen
|
|
72
|
+
|
|
73
|
+
size, = committed_state
|
|
74
|
+
raise ArgumentError, "replication start must equal committed size" unless start == size
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
append_replica_chunks(io, length, size)
|
|
78
|
+
if length.positive?
|
|
79
|
+
finish = start + length
|
|
80
|
+
slot, = Replication.trailer(@store, header, finish, finish)
|
|
81
|
+
Replication.storage_write { @store.sync }
|
|
82
|
+
outcome = :unknown
|
|
83
|
+
Replication.storage_write(outcome: outcome) do
|
|
84
|
+
@store.write_at(Database::ROOT_OFFSET, slot.to_bytes + [finish].pack("Q>"))
|
|
85
|
+
@store.sync
|
|
86
|
+
end
|
|
87
|
+
outcome = :committed
|
|
88
|
+
end
|
|
89
|
+
completed = true
|
|
90
|
+
ensure
|
|
91
|
+
recover_replica_application(size, completed, outcome)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Preserve durability reporting for interrupts and fatal errors.
|
|
96
|
+
# A release failure after successful application cannot undo durability.
|
|
97
|
+
if completed && !e.is_a?(CommitError)
|
|
98
|
+
raise CommitError.new(e.message, outcome: outcome || :not_committed), cause: e
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def append_replica_chunks(io, length, size)
|
|
105
|
+
Replication.storage_write { @store.truncate(size) } if @store.length > size
|
|
106
|
+
Replication.read_chunks(io, length) do |chunk|
|
|
107
|
+
Replication.storage_write { @store.append(chunk) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def recover_replica_application(size, completed, outcome)
|
|
112
|
+
@needs_reopen = true if outcome == :unknown
|
|
113
|
+
return if completed || outcome != :not_committed
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
@store.truncate(size)
|
|
117
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Rollback failure requires reopening even after an interrupt.
|
|
118
|
+
@needs_reopen = true
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private :append_replica_chunks, :recover_replica_application
|
|
122
|
+
|
|
123
|
+
def self.copy_replica(to:, source_io:, length:)
|
|
124
|
+
position(length)
|
|
125
|
+
raise ArgumentError, "full replica is shorter than a database" if length < Database::MINIMUM_SIZE
|
|
126
|
+
|
|
127
|
+
store = FileStore.open(to)
|
|
128
|
+
# @type var outcome: :not_committed | :committed | :unknown
|
|
129
|
+
outcome = :not_committed
|
|
130
|
+
completed = false
|
|
131
|
+
begin
|
|
132
|
+
store.with_exclusive_lock do
|
|
133
|
+
# FileStore measures bytes and has no collection #empty? method.
|
|
134
|
+
unless store.length.zero? # rubocop:disable Style/ZeroLengthPredicate
|
|
135
|
+
raise ArgumentError, "replica staging target must be empty"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
read_chunks(source_io, length) { |chunk| storage_write { store.append(chunk) } }
|
|
140
|
+
validate_replica(store, length)
|
|
141
|
+
|
|
142
|
+
outcome = :unknown
|
|
143
|
+
storage_write(outcome: outcome) { store.sync }
|
|
144
|
+
outcome = :committed
|
|
145
|
+
completed = true
|
|
146
|
+
ensure
|
|
147
|
+
cleanup_staged_replica(store, completed, outcome)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
database = Database.new(store: store, initialize_empty: false)
|
|
151
|
+
returned = true
|
|
152
|
+
database
|
|
153
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Report durable commits even when cleanup is interrupted.
|
|
154
|
+
raise CommitError.new(e.message, outcome: outcome), cause: e if completed && !e.is_a?(CommitError)
|
|
155
|
+
|
|
156
|
+
raise
|
|
157
|
+
ensure
|
|
158
|
+
store.send(:close_unreturned) unless returned
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.cleanup_staged_replica(store, completed, outcome)
|
|
163
|
+
return if completed || outcome != :not_committed
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
store.truncate(0)
|
|
167
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Preserve the original failure if staging cleanup fails.
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
private_class_method :cleanup_staged_replica
|
|
172
|
+
|
|
173
|
+
def self.validate_replica(store, length)
|
|
174
|
+
header = Header.parse(store.read_at(0, Header::SIZE))
|
|
175
|
+
root = store.read_at(Database::ROOT_OFFSET, 17)
|
|
176
|
+
unless Format.unsigned(Format.slice(root, 9, 8)) == length
|
|
177
|
+
raise CorruptionError, "full replica length does not match its root header"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
slot, = trailer(store, header, length, length)
|
|
181
|
+
return if root.byteslice(0, 9) == slot.to_bytes
|
|
182
|
+
|
|
183
|
+
raise CorruptionError, "full replica root does not match its trailer"
|
|
184
|
+
end
|
|
185
|
+
private_class_method :validate_replica
|
|
186
|
+
|
|
187
|
+
def self.position(value)
|
|
188
|
+
raise TypeError, "offset and length must be Integers" unless value.is_a?(Integer)
|
|
189
|
+
raise ArgumentError, "offset and length must be nonnegative" if value.negative?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.trailer(store, header, finish, bound)
|
|
193
|
+
raise CorruptionError, "invalid root trailer position" unless finish.between?(Database::MINIMUM_SIZE, bound)
|
|
194
|
+
|
|
195
|
+
position = finish - Database::TRAILER_SIZE
|
|
196
|
+
bytes = store.read_at(position, Database::TRAILER_SIZE)
|
|
197
|
+
slot = Slot.parse(Format.slice(bytes, 0, 9))
|
|
198
|
+
raise CorruptionError, "unknown root slot tag" unless Slot::TAGS.include?(slot.tag)
|
|
199
|
+
|
|
200
|
+
checksum = Format.unsigned(Format.slice(bytes, 17, 8))
|
|
201
|
+
expected = CRC64.call([header.file_id, position].pack("Q>Q>") + Format.slice(bytes, 0, 17))
|
|
202
|
+
raise CorruptionError, "invalid root trailer checksum" unless checksum == expected
|
|
203
|
+
|
|
204
|
+
[slot, checksum]
|
|
205
|
+
rescue EOFError => e
|
|
206
|
+
raise CorruptionError, e.message
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.read_chunks(io, length)
|
|
210
|
+
while length.positive?
|
|
211
|
+
count = [length, CHUNK_SIZE].min
|
|
212
|
+
bytes = io.read(count)
|
|
213
|
+
raise EOFError, "replication stream ended before its declared length" if bytes.nil? || bytes == ""
|
|
214
|
+
unless bytes.is_a?(String) && bytes.bytesize <= count
|
|
215
|
+
raise IOError, "replication reader returned an invalid chunk"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
yield bytes
|
|
219
|
+
length -= bytes.bytesize
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.write_all(io, bytes)
|
|
224
|
+
offset = 0
|
|
225
|
+
while offset < bytes.bytesize
|
|
226
|
+
count = io.write(Format.slice(bytes, offset..))
|
|
227
|
+
unless count.is_a?(Integer) && count.positive? && count <= bytes.bytesize - offset
|
|
228
|
+
raise IOError, "replication writer made invalid progress"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
offset += count
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def self.storage_write(outcome: :not_committed)
|
|
236
|
+
yield
|
|
237
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Every storage failure must retain its commit outcome.
|
|
238
|
+
raise CommitError.new(e.message, outcome: outcome), cause: e
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|