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,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