lutaml-store 0.2.4 → 0.3.1
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 +4 -4
- data/CLAUDE.md +6 -0
- data/README.adoc +100 -3
- data/docs/astro.config.mjs +12 -0
- data/docs/package-lock.json +6098 -0
- data/docs/package.json +18 -0
- data/docs/public/favicon.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
- data/docs/src/layouts/Base.astro +44 -0
- data/docs/src/layouts/Docs.astro +41 -0
- data/docs/src/pages/adapters.md +46 -0
- data/docs/src/pages/architecture.md +68 -0
- data/docs/src/pages/cloud-contract.md +48 -0
- data/docs/src/pages/formats.md +54 -0
- data/docs/src/pages/http-cache.md +43 -0
- data/docs/src/pages/index.md +82 -0
- data/docs/src/pages/quick-start.md +109 -0
- data/docs/src/pages/sources.md +83 -0
- data/docs/src/pages/stores.md +110 -0
- data/docs/src/styles/global.css +10 -0
- data/lib/lutaml/store/adapter/base.rb +14 -1
- data/lib/lutaml/store/adapter/filesystem.rb +283 -108
- data/lib/lutaml/store/adapter/memory.rb +16 -1
- data/lib/lutaml/store/adapter/sqlite.rb +26 -1
- data/lib/lutaml/store/basic_store.rb +11 -0
- data/lib/lutaml/store/cache_store.rb +104 -71
- data/lib/lutaml/store/config.rb +13 -1
- data/lib/lutaml/store/format.rb +13 -0
- data/lib/lutaml/store/manifest.rb +85 -0
- data/lib/lutaml/store/mirror.rb +94 -0
- data/lib/lutaml/store/repository.rb +178 -0
- data/lib/lutaml/store/source/base.rb +137 -0
- data/lib/lutaml/store/source/directory.rb +49 -0
- data/lib/lutaml/store/source/https.rb +109 -0
- data/lib/lutaml/store/source/rest.rb +61 -0
- data/lib/lutaml/store/source/zip.rb +52 -0
- data/lib/lutaml/store/source.rb +55 -0
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +5 -0
- metadata +29 -2
|
@@ -3,13 +3,46 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "json"
|
|
6
|
+
require "monitor"
|
|
7
|
+
require "securerandom"
|
|
6
8
|
|
|
7
9
|
module Lutaml
|
|
8
10
|
module Store
|
|
9
11
|
module Adapter
|
|
12
|
+
# One data file per key, with an optional `.meta` file beside it.
|
|
13
|
+
#
|
|
14
|
+
# File names percent-encode every byte outside `[A-Za-z0-9._-]` and a
|
|
15
|
+
# leading ".", so distinct keys never share a file and no name is hidden
|
|
16
|
+
# or climbs out of the root. The empty key is named "%". A name longer
|
|
17
|
+
# than MAX_NAME_BYTES becomes "~" + SHA-256 of the key, and the `.meta`
|
|
18
|
+
# file keeps the key. Files written by 0.3.0 (lossy "_" names) are still
|
|
19
|
+
# read, and are moved to the new name on the next write.
|
|
20
|
+
#
|
|
21
|
+
# Writers hold an exclusive lock on `<root>/.lock` (plus a per-root
|
|
22
|
+
# Monitor for threads); readers hold a shared lock. Files are written
|
|
23
|
+
# to a unique temp file and renamed into place.
|
|
10
24
|
class FileSystem < Base
|
|
11
25
|
DEFAULT_EXTENSION = ".data"
|
|
12
26
|
METADATA_EXTENSION = ".meta"
|
|
27
|
+
LOCK_FILE = ".lock"
|
|
28
|
+
MAX_NAME_BYTES = 200
|
|
29
|
+
DIGEST_PREFIX = "~"
|
|
30
|
+
JSON_FORMAT = "json"
|
|
31
|
+
UNSAFE_BYTE = /[^A-Za-z0-9._-]/n
|
|
32
|
+
LEGACY_UNSAFE_CHAR = /[^a-zA-Z0-9._-]/
|
|
33
|
+
EMPTY_KEY_NAME = "%"
|
|
34
|
+
HELD_LOCKS = :lutaml_store_filesystem_locks
|
|
35
|
+
|
|
36
|
+
@monitors = {}
|
|
37
|
+
@monitors_guard = Mutex.new
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# One Monitor per lock file, shared by every adapter instance in
|
|
41
|
+
# the process, so two instances on one root exclude each other.
|
|
42
|
+
def monitor_for(lock_path)
|
|
43
|
+
@monitors_guard.synchronize { @monitors[lock_path] ||= ::Monitor.new }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
13
46
|
|
|
14
47
|
def initialize(config = {})
|
|
15
48
|
super
|
|
@@ -18,137 +51,134 @@ module Lutaml
|
|
|
18
51
|
@create_directories = @config.fetch(:create_directories, true)
|
|
19
52
|
@integrity_enabled = @config.fetch(:integrity_checks, true)
|
|
20
53
|
@integrity_algorithm = @config.fetch(:integrity_algorithm, "sha256")
|
|
54
|
+
@lock_path = File.expand_path(File.join(@root_path, LOCK_FILE))
|
|
55
|
+
@monitor = self.class.monitor_for(@lock_path)
|
|
21
56
|
|
|
22
57
|
setup_directory_structure
|
|
23
58
|
end
|
|
24
59
|
|
|
25
60
|
def get(key)
|
|
26
|
-
|
|
27
|
-
return
|
|
61
|
+
status, result = with_lock(:sh) { read_verified(key) }
|
|
62
|
+
return result unless status == :corrupt
|
|
28
63
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
verify_data_integrity(data, metadata)
|
|
34
|
-
rescue Integrity::IntegrityError => e
|
|
35
|
-
repaired_data = repair_corruption(key)
|
|
36
|
-
return repaired_data if repaired_data
|
|
64
|
+
# Repair writes, so it runs under the exclusive lock, never the shared one.
|
|
65
|
+
with_lock(:ex) do
|
|
66
|
+
status, result = read_verified(key)
|
|
67
|
+
next result unless status == :corrupt
|
|
37
68
|
|
|
38
|
-
raise
|
|
69
|
+
repair_corruption(key) || raise(result)
|
|
39
70
|
end
|
|
40
|
-
|
|
41
|
-
data
|
|
42
71
|
end
|
|
43
72
|
|
|
44
73
|
def set(key, value)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
full_metadata = wrap_with_integrity(value)
|
|
49
|
-
file_safe_write(file_path, value)
|
|
50
|
-
write_metadata(key, full_metadata) if full_metadata.any?
|
|
74
|
+
content, format = encode_value(value)
|
|
75
|
+
with_lock(:ex) { write_entry(key, content, format) }
|
|
51
76
|
value
|
|
52
77
|
end
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
# Atomic read-modify-write: the exclusive lock is held across the
|
|
80
|
+
# read, the block and the write, for threads and processes.
|
|
81
|
+
def update(key)
|
|
82
|
+
with_lock(:ex) do
|
|
83
|
+
new_value = yield(get(key))
|
|
84
|
+
set(key, new_value)
|
|
85
|
+
new_value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
57
88
|
|
|
58
|
-
|
|
89
|
+
def transaction(&block)
|
|
90
|
+
with_lock(:ex, &block)
|
|
91
|
+
end
|
|
59
92
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
def delete(key)
|
|
94
|
+
with_lock(:ex) do
|
|
95
|
+
file_path = existing_path_for_key(key)
|
|
96
|
+
next nil unless file_path
|
|
63
97
|
|
|
64
|
-
|
|
98
|
+
value = decode_value(file_safe_read(file_path), read_metadata(metadata_path_for(file_path))[:format])
|
|
99
|
+
remove_entry(file_path)
|
|
100
|
+
remove_legacy_entry(key)
|
|
65
101
|
|
|
66
|
-
|
|
102
|
+
value
|
|
103
|
+
end
|
|
67
104
|
end
|
|
68
105
|
|
|
69
106
|
def exists?(key)
|
|
70
|
-
|
|
107
|
+
!existing_path_for_key(key).nil?
|
|
71
108
|
end
|
|
72
109
|
|
|
73
110
|
def keys
|
|
74
111
|
return [] unless Dir.exist?(@root_path)
|
|
75
112
|
|
|
76
|
-
|
|
77
|
-
key_from_path(file_path)
|
|
113
|
+
with_lock(:sh) do
|
|
114
|
+
data_files.filter_map { |file_path| key_from_path(file_path) }
|
|
78
115
|
end
|
|
79
116
|
end
|
|
80
117
|
|
|
81
118
|
def all
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
|
|
85
|
-
next unless File.file?(file_path)
|
|
86
|
-
|
|
87
|
-
key = key_from_path(file_path)
|
|
119
|
+
keys.each_with_object({}) do |key, result|
|
|
88
120
|
value = get(key)
|
|
89
|
-
result[key] = value
|
|
121
|
+
result[key] = value unless value.nil?
|
|
90
122
|
end
|
|
91
|
-
|
|
92
|
-
result
|
|
93
123
|
end
|
|
94
124
|
|
|
95
125
|
def clear
|
|
96
126
|
return 0 unless Dir.exist?(@root_path)
|
|
97
127
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
128
|
+
with_lock(:ex) do
|
|
129
|
+
count = 0
|
|
130
|
+
data_files.each do |file_path|
|
|
101
131
|
File.delete(file_path)
|
|
102
132
|
count += 1
|
|
103
133
|
end
|
|
104
|
-
end
|
|
105
134
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
Dir.glob(File.join(@root_path, "**", "*#{METADATA_EXTENSION}")).each do |file_path|
|
|
136
|
+
File.delete(file_path) if File.file?(file_path)
|
|
137
|
+
end
|
|
109
138
|
|
|
110
|
-
|
|
139
|
+
cleanup_empty_directories(@root_path, preserve_root: true)
|
|
111
140
|
|
|
112
|
-
|
|
141
|
+
count
|
|
142
|
+
end
|
|
113
143
|
end
|
|
114
144
|
|
|
115
145
|
def size
|
|
116
146
|
return 0 unless Dir.exist?(@root_path)
|
|
117
147
|
|
|
118
|
-
|
|
148
|
+
data_files.size
|
|
119
149
|
end
|
|
120
150
|
|
|
121
151
|
def close; end
|
|
122
152
|
|
|
123
153
|
def verify_integrity
|
|
124
154
|
corrupted_keys = []
|
|
155
|
+
all_keys = keys
|
|
125
156
|
|
|
126
|
-
|
|
157
|
+
all_keys.each do |key|
|
|
127
158
|
get(key)
|
|
128
159
|
rescue Integrity::IntegrityError
|
|
129
160
|
corrupted_keys << key
|
|
130
161
|
end
|
|
131
162
|
|
|
132
163
|
{
|
|
133
|
-
total_keys:
|
|
164
|
+
total_keys: all_keys.size,
|
|
134
165
|
corrupted_keys: corrupted_keys,
|
|
135
166
|
integrity_ok: corrupted_keys.empty?
|
|
136
167
|
}
|
|
137
168
|
end
|
|
138
169
|
|
|
139
170
|
def repair_corruption(key, backup_data = nil)
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
with_lock(:ex) do
|
|
172
|
+
file_path = existing_path_for_key(key)
|
|
173
|
+
next nil unless file_path
|
|
142
174
|
|
|
143
|
-
|
|
144
|
-
|
|
175
|
+
format = read_metadata(metadata_path_for(file_path))[:format]
|
|
176
|
+
repaired_data = Integrity.repair_data(file_safe_read(file_path), backup_data)
|
|
177
|
+
next nil unless Integrity.valid_data?(repaired_data)
|
|
145
178
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return repaired_data
|
|
179
|
+
write_entry(key, repaired_data, format)
|
|
180
|
+
decode_value(repaired_data, format)
|
|
149
181
|
end
|
|
150
|
-
|
|
151
|
-
nil
|
|
152
182
|
end
|
|
153
183
|
|
|
154
184
|
def stats
|
|
@@ -167,31 +197,197 @@ module Lutaml
|
|
|
167
197
|
FileUtils.mkdir_p(@root_path) unless Dir.exist?(@root_path)
|
|
168
198
|
end
|
|
169
199
|
|
|
200
|
+
# ── Locking ──
|
|
201
|
+
|
|
202
|
+
# Re-entrant for the thread that already holds a lock on this root.
|
|
203
|
+
# A shared lock cannot be upgraded: flock would deadlock on its own fd.
|
|
204
|
+
def with_lock(mode, &block)
|
|
205
|
+
held = held_lock_mode
|
|
206
|
+
if held
|
|
207
|
+
raise BackendError, "Cannot take an exclusive lock inside a shared lock" if mode == :ex && held == :sh
|
|
208
|
+
|
|
209
|
+
return yield
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if mode == :ex
|
|
213
|
+
@monitor.synchronize { flock_lock_file(File::LOCK_EX, :ex, &block) }
|
|
214
|
+
else
|
|
215
|
+
flock_lock_file(File::LOCK_SH, :sh, &block)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def flock_lock_file(operation, mode)
|
|
220
|
+
# Nothing to read and nothing to protect before the root exists.
|
|
221
|
+
return yield if mode == :sh && !Dir.exist?(@root_path)
|
|
222
|
+
|
|
223
|
+
ensure_directory_exists(@root_path)
|
|
224
|
+
File.open(@lock_path, File::RDWR | File::CREAT, 0o644) do |lock_file|
|
|
225
|
+
lock_file.flock(operation)
|
|
226
|
+
held_locks[@lock_path] = { mode: mode, pid: Process.pid, file: lock_file }
|
|
227
|
+
yield
|
|
228
|
+
ensure
|
|
229
|
+
held_locks.delete(@lock_path)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A lock entry copied into a forked child is not the child's lock.
|
|
234
|
+
# The child closes its copy of the lock file, so the parent's flock
|
|
235
|
+
# is released when the parent closes its own descriptor.
|
|
236
|
+
def held_lock_mode
|
|
237
|
+
entry = held_locks[@lock_path]
|
|
238
|
+
return nil unless entry
|
|
239
|
+
return entry[:mode] if entry[:pid] == Process.pid
|
|
240
|
+
|
|
241
|
+
held_locks.delete(@lock_path)
|
|
242
|
+
entry[:file].close unless entry[:file].closed?
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Per thread, not per fiber: an Enumerator or Fiber that runs inside
|
|
247
|
+
# a locked block re-enters the lock instead of waiting on itself.
|
|
248
|
+
def held_locks
|
|
249
|
+
Thread.current.thread_variable_get(HELD_LOCKS) ||
|
|
250
|
+
Thread.current.thread_variable_set(HELD_LOCKS, {})
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ── Entries ──
|
|
254
|
+
|
|
255
|
+
# Returns [:ok, value], [:missing, nil] or [:corrupt, error].
|
|
256
|
+
def read_verified(key)
|
|
257
|
+
file_path = existing_path_for_key(key)
|
|
258
|
+
return [:missing, nil] unless file_path
|
|
259
|
+
|
|
260
|
+
data = file_safe_read(file_path)
|
|
261
|
+
metadata = read_metadata(metadata_path_for(file_path))
|
|
262
|
+
|
|
263
|
+
begin
|
|
264
|
+
verify_data_integrity(data, metadata)
|
|
265
|
+
rescue Integrity::IntegrityError => e
|
|
266
|
+
return [:corrupt, e]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
[:ok, decode_value(data, metadata[:format])]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def write_entry(key, content, format)
|
|
273
|
+
file_path = path_for_key(key)
|
|
274
|
+
metadata_path = metadata_path_for(file_path)
|
|
275
|
+
ensure_directory_exists(File.dirname(file_path))
|
|
276
|
+
|
|
277
|
+
metadata = {}
|
|
278
|
+
metadata[:integrity] = create_integrity_metadata(content) if @integrity_enabled
|
|
279
|
+
metadata[:format] = format if format
|
|
280
|
+
metadata[:key] = key.to_s if File.basename(file_path).start_with?(DIGEST_PREFIX)
|
|
281
|
+
|
|
282
|
+
file_safe_write(file_path, content)
|
|
283
|
+
if metadata.any?
|
|
284
|
+
file_safe_write(metadata_path, JSON.generate(metadata))
|
|
285
|
+
elsif File.exist?(metadata_path)
|
|
286
|
+
File.delete(metadata_path)
|
|
287
|
+
end
|
|
288
|
+
remove_legacy_entry(key)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def remove_entry(file_path)
|
|
292
|
+
metadata_path = metadata_path_for(file_path)
|
|
293
|
+
File.delete(file_path) if File.exist?(file_path)
|
|
294
|
+
File.delete(metadata_path) if File.exist?(metadata_path)
|
|
295
|
+
cleanup_empty_directories(File.dirname(file_path))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def remove_legacy_entry(key)
|
|
299
|
+
legacy_path = legacy_path_for_key(key)
|
|
300
|
+
remove_entry(legacy_path) if legacy_path && File.exist?(legacy_path)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def encode_value(value)
|
|
304
|
+
return [value, nil] if value.is_a?(String)
|
|
305
|
+
|
|
306
|
+
[JSON.generate(value), JSON_FORMAT]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def decode_value(content, format)
|
|
310
|
+
return content unless format == JSON_FORMAT
|
|
311
|
+
|
|
312
|
+
JSON.parse(content)
|
|
313
|
+
rescue JSON::ParserError
|
|
314
|
+
content
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# ── Paths ──
|
|
318
|
+
|
|
319
|
+
def data_files
|
|
320
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).select { |path| File.file?(path) }
|
|
321
|
+
end
|
|
322
|
+
|
|
170
323
|
def path_for_key(key)
|
|
171
|
-
|
|
324
|
+
shard_path(file_name_for_key(key))
|
|
325
|
+
end
|
|
172
326
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
File.join(@root_path,
|
|
327
|
+
def shard_path(name)
|
|
328
|
+
if name.length >= 2
|
|
329
|
+
File.join(@root_path, name[0, 2], "#{name}#{@extension}")
|
|
176
330
|
else
|
|
177
|
-
File.join(@root_path, "#{
|
|
331
|
+
File.join(@root_path, "#{name}#{@extension}")
|
|
178
332
|
end
|
|
179
333
|
end
|
|
180
334
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
335
|
+
# The current path, or the 0.3.0 path when only that one exists.
|
|
336
|
+
def existing_path_for_key(key)
|
|
337
|
+
file_path = path_for_key(key)
|
|
338
|
+
return file_path if File.exist?(file_path)
|
|
339
|
+
|
|
340
|
+
legacy_path = legacy_path_for_key(key)
|
|
341
|
+
legacy_path if legacy_path && File.exist?(legacy_path)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# 0.3.0 replaced unsafe characters with "_". Nil when that name is
|
|
345
|
+
# the current one, or when it would leave the root ("..").
|
|
346
|
+
def legacy_path_for_key(key)
|
|
347
|
+
name = key.to_s.gsub(LEGACY_UNSAFE_CHAR, "_")
|
|
348
|
+
return nil if name.empty? || name.start_with?(".")
|
|
349
|
+
|
|
350
|
+
legacy_path = shard_path(name)
|
|
351
|
+
legacy_path unless legacy_path == path_for_key(key)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def metadata_path_for(data_path)
|
|
355
|
+
"#{data_path.delete_suffix(@extension)}#{METADATA_EXTENSION}"
|
|
184
356
|
end
|
|
185
357
|
|
|
186
358
|
def key_from_path(file_path)
|
|
187
|
-
|
|
188
|
-
|
|
359
|
+
name = File.basename(file_path, @extension)
|
|
360
|
+
return decode_key(name) unless name.start_with?(DIGEST_PREFIX)
|
|
361
|
+
|
|
362
|
+
read_metadata(metadata_path_for(file_path))[:key]
|
|
189
363
|
end
|
|
190
364
|
|
|
191
|
-
def
|
|
192
|
-
key
|
|
365
|
+
def file_name_for_key(key)
|
|
366
|
+
encoded = encode_key(key)
|
|
367
|
+
return encoded if encoded.bytesize <= MAX_NAME_BYTES
|
|
368
|
+
|
|
369
|
+
"#{DIGEST_PREFIX}#{Digest::SHA256.hexdigest(key.to_s)}"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def encode_key(key)
|
|
373
|
+
raw = key.to_s
|
|
374
|
+
return EMPTY_KEY_NAME if raw.empty?
|
|
375
|
+
|
|
376
|
+
encoded = raw.b.gsub(UNSAFE_BYTE) { |byte| format("%%%02X", byte.ord) }
|
|
377
|
+
encoded = "%2E#{encoded[1..]}" if encoded.start_with?(".")
|
|
378
|
+
encoded.force_encoding(Encoding::UTF_8)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def decode_key(name)
|
|
382
|
+
return "" if name == EMPTY_KEY_NAME
|
|
383
|
+
|
|
384
|
+
decoded = name.b.gsub(/%([0-9A-F]{2})/n) { Regexp.last_match(1).hex.chr }
|
|
385
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
386
|
+
decoded.valid_encoding? ? decoded : decoded.b
|
|
193
387
|
end
|
|
194
388
|
|
|
389
|
+
# ── Integrity and metadata ──
|
|
390
|
+
|
|
195
391
|
def create_integrity_metadata(data)
|
|
196
392
|
return {} unless @integrity_enabled
|
|
197
393
|
|
|
@@ -205,32 +401,16 @@ module Lutaml
|
|
|
205
401
|
Integrity.verify_integrity_metadata(data, metadata[:integrity])
|
|
206
402
|
end
|
|
207
403
|
|
|
208
|
-
def
|
|
209
|
-
metadata = user_metadata.dup
|
|
210
|
-
metadata[:integrity] = create_integrity_metadata(data) if @integrity_enabled
|
|
211
|
-
metadata
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def write_metadata(key, metadata)
|
|
215
|
-
return unless metadata.any?
|
|
216
|
-
|
|
217
|
-
metadata_path = metadata_path_for_key(key)
|
|
218
|
-
ensure_directory_exists(File.dirname(metadata_path))
|
|
219
|
-
|
|
220
|
-
File.write(metadata_path, JSON.generate(metadata))
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def read_metadata(key)
|
|
224
|
-
metadata_path = metadata_path_for_key(key)
|
|
404
|
+
def read_metadata(metadata_path)
|
|
225
405
|
return {} unless File.exist?(metadata_path)
|
|
226
406
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
{}
|
|
231
|
-
end
|
|
407
|
+
JSON.parse(File.read(metadata_path), symbolize_names: true)
|
|
408
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
409
|
+
{}
|
|
232
410
|
end
|
|
233
411
|
|
|
412
|
+
# ── Files ──
|
|
413
|
+
|
|
234
414
|
def ensure_directory_exists(dir_path)
|
|
235
415
|
return if Dir.exist?(dir_path)
|
|
236
416
|
|
|
@@ -249,38 +429,33 @@ module Lutaml
|
|
|
249
429
|
end
|
|
250
430
|
|
|
251
431
|
def file_safe_read(file_path)
|
|
252
|
-
File.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
end
|
|
432
|
+
content = File.binread(file_path)
|
|
433
|
+
utf8 = content.dup.force_encoding(Encoding::UTF_8)
|
|
434
|
+
utf8.valid_encoding? ? utf8 : content
|
|
256
435
|
rescue StandardError => e
|
|
257
436
|
raise BackendError, "Failed to read file #{file_path}: #{e.message}"
|
|
258
437
|
end
|
|
259
438
|
|
|
439
|
+
# The temp name is unique per call, so concurrent writers never share
|
|
440
|
+
# a temp file; rename makes the new content visible atomically.
|
|
260
441
|
def file_safe_write(file_path, content)
|
|
261
|
-
temp_path = "#{file_path}.tmp.#{Process.pid}"
|
|
442
|
+
temp_path = "#{file_path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}"
|
|
262
443
|
|
|
263
444
|
File.open(temp_path, "wb") do |file|
|
|
264
|
-
file.flock(File::LOCK_EX)
|
|
265
445
|
file.write(content)
|
|
266
446
|
file.fsync
|
|
267
447
|
end
|
|
268
448
|
|
|
269
449
|
File.rename(temp_path, file_path)
|
|
270
450
|
rescue StandardError => e
|
|
271
|
-
|
|
451
|
+
FileUtils.rm_f(temp_path)
|
|
272
452
|
raise BackendError, "Failed to write file #{file_path}: #{e.message}"
|
|
273
453
|
end
|
|
274
454
|
|
|
275
455
|
def calculate_disk_usage
|
|
276
456
|
return 0 unless Dir.exist?(@root_path)
|
|
277
457
|
|
|
278
|
-
|
|
279
|
-
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
|
|
280
|
-
total_size += File.size(file_path) if File.file?(file_path)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
total_size
|
|
458
|
+
data_files.sum { |file_path| File.size(file_path) }
|
|
284
459
|
end
|
|
285
460
|
end
|
|
286
461
|
end
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
module Lutaml
|
|
4
6
|
module Store
|
|
5
7
|
module Adapter
|
|
6
8
|
# In-memory key-value adapter optimized for read-heavy workloads.
|
|
7
9
|
# Writes are synchronized; reads are lock-free using snapshot copies.
|
|
10
|
+
# The write lock is a re-entrant Monitor so #update can call #set.
|
|
8
11
|
class Memory < Base
|
|
9
12
|
def initialize(config = {})
|
|
10
13
|
super
|
|
11
14
|
@data = {}
|
|
12
|
-
@write_mutex =
|
|
15
|
+
@write_mutex = ::Monitor.new
|
|
13
16
|
@read_snapshot = {}.freeze
|
|
14
17
|
@snapshot_stale = true
|
|
15
18
|
@ttl_enabled = @config.fetch(:ttl_enabled, false)
|
|
@@ -72,6 +75,18 @@ module Lutaml
|
|
|
72
75
|
value
|
|
73
76
|
end
|
|
74
77
|
|
|
78
|
+
# Atomic read-modify-write across threads. Keeps the key's expiry.
|
|
79
|
+
def update(key)
|
|
80
|
+
@write_mutex.synchronize do
|
|
81
|
+
old_value = get(key)
|
|
82
|
+
expires_at = @ttl_data&.[](key)
|
|
83
|
+
new_value = yield(old_value)
|
|
84
|
+
set(key, new_value)
|
|
85
|
+
@ttl_data[key] = expires_at if expires_at
|
|
86
|
+
new_value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
75
90
|
def delete(key)
|
|
76
91
|
@write_mutex.synchronize do
|
|
77
92
|
existed = @data.key?(key)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "monitor"
|
|
4
5
|
|
|
5
6
|
module Lutaml
|
|
6
7
|
module Store
|
|
@@ -19,6 +20,7 @@ module Lutaml
|
|
|
19
20
|
@db_path = @config[:path] || raise(ConfigurationError, "SQLite adapter requires :path config")
|
|
20
21
|
@table_name = @config[:table_name] || DEFAULT_TABLE_NAME
|
|
21
22
|
@timeout = @config[:timeout] || 30_000
|
|
23
|
+
@update_lock = ::Monitor.new
|
|
22
24
|
|
|
23
25
|
setup_database
|
|
24
26
|
end
|
|
@@ -178,14 +180,37 @@ module Lutaml
|
|
|
178
180
|
results
|
|
179
181
|
end
|
|
180
182
|
|
|
183
|
+
# Atomic read-modify-write. BEGIN IMMEDIATE takes the database write
|
|
184
|
+
# lock before the read, so no other process can write in between;
|
|
185
|
+
# the Monitor keeps threads that share this connection apart.
|
|
186
|
+
def update(key, &block)
|
|
187
|
+
@update_lock.synchronize do
|
|
188
|
+
if @db.transaction_active?
|
|
189
|
+
update_in_transaction(key, &block)
|
|
190
|
+
else
|
|
191
|
+
@db.transaction(:immediate) { update_in_transaction(key, &block) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
rescue SQLite3::Exception => e
|
|
195
|
+
raise BackendError, "Update failed: #{e.message}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Holds the update lock too, so a concurrent #update from another
|
|
199
|
+
# thread cannot run inside this transaction.
|
|
181
200
|
def transaction(&block)
|
|
182
|
-
@db.transaction(&block)
|
|
201
|
+
@update_lock.synchronize { @db.transaction(&block) }
|
|
183
202
|
rescue SQLite3::Exception => e
|
|
184
203
|
raise BackendError, "Transaction failed: #{e.message}"
|
|
185
204
|
end
|
|
186
205
|
|
|
187
206
|
private
|
|
188
207
|
|
|
208
|
+
def update_in_transaction(key)
|
|
209
|
+
new_value = yield(get(key))
|
|
210
|
+
set(key, new_value)
|
|
211
|
+
new_value
|
|
212
|
+
end
|
|
213
|
+
|
|
189
214
|
def setup_database
|
|
190
215
|
@db = SQLite3::Database.new(@db_path)
|
|
191
216
|
@db.busy_timeout = @timeout
|
|
@@ -57,6 +57,17 @@ module Lutaml
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Atomic read-modify-write through the adapter. The block gets the
|
|
61
|
+
# adapter's current value, not the cached one.
|
|
62
|
+
def update(key, &block)
|
|
63
|
+
with_monitoring(:set) do
|
|
64
|
+
value = @adapter.update(key, &block)
|
|
65
|
+
@cache&.set(key, value)
|
|
66
|
+
emit_event(:set, key: key, value: value)
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
60
71
|
def delete(key)
|
|
61
72
|
with_monitoring(:delete) do
|
|
62
73
|
result = @adapter.delete(key)
|