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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +6 -0
  3. data/README.adoc +100 -3
  4. data/docs/astro.config.mjs +12 -0
  5. data/docs/package-lock.json +6098 -0
  6. data/docs/package.json +18 -0
  7. data/docs/public/favicon.svg +1 -0
  8. data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
  9. data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
  10. data/docs/src/layouts/Base.astro +44 -0
  11. data/docs/src/layouts/Docs.astro +41 -0
  12. data/docs/src/pages/adapters.md +46 -0
  13. data/docs/src/pages/architecture.md +68 -0
  14. data/docs/src/pages/cloud-contract.md +48 -0
  15. data/docs/src/pages/formats.md +54 -0
  16. data/docs/src/pages/http-cache.md +43 -0
  17. data/docs/src/pages/index.md +82 -0
  18. data/docs/src/pages/quick-start.md +109 -0
  19. data/docs/src/pages/sources.md +83 -0
  20. data/docs/src/pages/stores.md +110 -0
  21. data/docs/src/styles/global.css +10 -0
  22. data/lib/lutaml/store/adapter/base.rb +14 -1
  23. data/lib/lutaml/store/adapter/filesystem.rb +283 -108
  24. data/lib/lutaml/store/adapter/memory.rb +16 -1
  25. data/lib/lutaml/store/adapter/sqlite.rb +26 -1
  26. data/lib/lutaml/store/basic_store.rb +11 -0
  27. data/lib/lutaml/store/cache_store.rb +104 -71
  28. data/lib/lutaml/store/config.rb +13 -1
  29. data/lib/lutaml/store/format.rb +13 -0
  30. data/lib/lutaml/store/manifest.rb +85 -0
  31. data/lib/lutaml/store/mirror.rb +94 -0
  32. data/lib/lutaml/store/repository.rb +178 -0
  33. data/lib/lutaml/store/source/base.rb +137 -0
  34. data/lib/lutaml/store/source/directory.rb +49 -0
  35. data/lib/lutaml/store/source/https.rb +109 -0
  36. data/lib/lutaml/store/source/rest.rb +61 -0
  37. data/lib/lutaml/store/source/zip.rb +52 -0
  38. data/lib/lutaml/store/source.rb +55 -0
  39. data/lib/lutaml/store/version.rb +1 -1
  40. data/lib/lutaml/store.rb +5 -0
  41. 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
- file_path = path_for_key(key)
27
- return nil unless File.exist?(file_path)
61
+ status, result = with_lock(:sh) { read_verified(key) }
62
+ return result unless status == :corrupt
28
63
 
29
- data = file_safe_read(file_path)
30
- metadata = read_metadata(key)
31
-
32
- begin
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 e
69
+ repair_corruption(key) || raise(result)
39
70
  end
40
-
41
- data
42
71
  end
43
72
 
44
73
  def set(key, value)
45
- file_path = path_for_key(key)
46
- ensure_directory_exists(File.dirname(file_path))
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
- def delete(key)
55
- file_path = path_for_key(key)
56
- metadata_path = metadata_path_for_key(key)
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
- return nil unless File.exist?(file_path)
89
+ def transaction(&block)
90
+ with_lock(:ex, &block)
91
+ end
59
92
 
60
- value = file_safe_read(file_path)
61
- File.delete(file_path)
62
- File.delete(metadata_path) if File.exist?(metadata_path)
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
- cleanup_empty_directories(File.dirname(file_path))
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
- value
102
+ value
103
+ end
67
104
  end
68
105
 
69
106
  def exists?(key)
70
- File.exist?(path_for_key(key))
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
- Dir.glob(File.join(@root_path, "**", "*#{@extension}")).filter_map do |file_path|
77
- key_from_path(file_path) if File.file?(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
- result = {}
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 if 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
- count = 0
99
- Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
100
- if File.file?(file_path)
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
- Dir.glob(File.join(@root_path, "**", "*#{METADATA_EXTENSION}")).each do |file_path|
107
- File.delete(file_path) if File.file?(file_path)
108
- end
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
- cleanup_empty_directories(@root_path, preserve_root: true)
139
+ cleanup_empty_directories(@root_path, preserve_root: true)
111
140
 
112
- count
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
- Dir.glob(File.join(@root_path, "**", "*#{@extension}")).count { |path| File.file?(path) }
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
- keys.each do |key|
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: keys.size,
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
- file_path = path_for_key(key)
141
- return nil unless File.exist?(file_path)
171
+ with_lock(:ex) do
172
+ file_path = existing_path_for_key(key)
173
+ next nil unless file_path
142
174
 
143
- corrupted_data = file_safe_read(file_path)
144
- repaired_data = Integrity.repair_data(corrupted_data, backup_data)
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
- if Integrity.valid_data?(repaired_data)
147
- set(key, repaired_data)
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
- safe_key = sanitize_key(key)
324
+ shard_path(file_name_for_key(key))
325
+ end
172
326
 
173
- if safe_key.length >= 2
174
- subdir = safe_key[0, 2]
175
- File.join(@root_path, subdir, "#{safe_key}#{@extension}")
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, "#{safe_key}#{@extension}")
331
+ File.join(@root_path, "#{name}#{@extension}")
178
332
  end
179
333
  end
180
334
 
181
- def metadata_path_for_key(key)
182
- data_path = path_for_key(key)
183
- data_path.sub(@extension, METADATA_EXTENSION)
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
- relative_path = file_path.sub(@root_path, "").sub(%r{^/}, "")
188
- File.basename(relative_path, @extension)
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 sanitize_key(key)
192
- key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
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 wrap_with_integrity(data, user_metadata = {})
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
- begin
228
- JSON.parse(File.read(metadata_path), symbolize_names: true)
229
- rescue JSON::ParserError
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.open(file_path, "rb") do |file|
253
- file.flock(File::LOCK_SH)
254
- file.read
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
- File.delete(temp_path) if File.exist?(temp_path)
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
- total_size = 0
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 = Mutex.new
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)