lutaml-store 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34e9babf679b2654a563c89d68552afd0b2d98d5725656eb9baa2f1a878a80f4
4
- data.tar.gz: 599409d62bfe96b0e57147f620aef55ea88abe40b5183bdfb02511d3a91ea281
3
+ metadata.gz: 4481af10b7c80db97bffbe2e1ad5119d615370596c46c88c84e536411d1ffdda
4
+ data.tar.gz: 618ad367561533d0aa576ef713dfb384173e34ec1ec0047e3cbab400af9c4dce
5
5
  SHA512:
6
- metadata.gz: 77f4022441c6b6ab2f63b5106a2ca3bba16abab82bf75adbe8569ec73a0eb6cd824891920e5571e7a2f7b39c9d56ac5bd2824418f24aa8577abf893645941062
7
- data.tar.gz: dbe7804392b8b32135c77f3766b6976a8111a357b40bfdad563906d59205a0305dfd3f467b371ba423859a6ebbb8a7fa8d8baef905875a6097478763ed2d6b43
6
+ metadata.gz: 2e22cb07d0781f6aba2c2ebc043322e5a756519284740b74661906b6f00cbce8536291967868b86fd2a1908de46e6c1d4450f35c517d4919f73020d65890dc63
7
+ data.tar.gz: 26f2ef6b87a85e2c020571319640ceed71955ace41c97c2052857b97bd8d68e599cf466cd349f31c802a0fda7172cd857ce1bfbed952076b7425aea07c7d2de0
data/CLAUDE.md CHANGED
@@ -49,6 +49,12 @@ All inherit from `Format::Base`. Six formats: `Yaml`, `Yamls`, `Json`, `Jsonl`,
49
49
 
50
50
  All inherit from `Adapter::Base`. Three backends: `Memory`, `FileSystem`, `SQLite`. Registered in `Adapter` module and resolved via `Adapter.resolve(:type, options)`. New adapters can be added with `Adapter.register(:custom, CustomClass)` without modifying existing code (OCP).
51
51
 
52
+ `Base#each_key` defaults to `keys.each`; `Base#update(key) { |old| new }` defaults to get + set inside `transaction`. Memory, FileSystem and Sqlite override `update` to be atomic. `FileSystem` percent-encodes file names (digest + key in `.meta` for long names), writes non-String values as JSON (`format: "json"` in `.meta`), and locks `<root>/.lock` (`with_lock(:ex/:sh)`, re-entrant per thread; a shared lock cannot be upgraded).
53
+
54
+ Inside `Lutaml::Store`, a bare `Monitor` resolves to `Lutaml::Store::Monitor` (the stats collector) — write `::Monitor` for Ruby's re-entrant lock.
55
+
56
+ Specs that load `sqlite3` must sort after `autoload_spec.rb` (see `sqlite_scale_spec.rb`). Run specs with a UTF-8 locale (`LANG=C.UTF-8`); the anti-pattern guard fails under US-ASCII.
57
+
52
58
  ### PackageStore and transports
53
59
 
54
60
  `PackageStore` provides structured multi-model persistence. `PackageDefinition` declares which models, assets, and metadata the package contains. Transports (`DirectoryTransport`, `ZipTransport`) handle reading/writing to disk. Format handlers determine serialization per model entry.
data/README.adoc CHANGED
@@ -228,7 +228,21 @@ with optional caching, monitoring, and event emission.
228
228
  === Storage adapters
229
229
 
230
230
  All adapters inherit from `Adapter::Base` and provide `get`, `set`, `delete`,
231
- `exists?`, `keys`, `all`, `clear`, `size`, `each_key`, and bulk operations.
231
+ `exists?`, `keys`, `all`, `clear`, `size`, `each_key`, `update`, and bulk
232
+ operations. `each_key` defaults to `keys.each`, so a custom adapter only needs
233
+ `keys`.
234
+
235
+ `update(key) { |old| new }` is a read-modify-write: the block gets the current
236
+ value (`nil` when the key is missing) and its result is stored. It is atomic
237
+ across threads for Memory, and across threads and processes for FileSystem and
238
+ SQLite. `BasicStore#update` calls it and refreshes the read cache.
239
+
240
+ [source,ruby]
241
+ ----
242
+ store = Lutaml::Store::BasicStore.new(adapter_type: :filesystem,
243
+ adapter_options: { path: "./data" })
244
+ store.update("counter") { |n| (n.to_i + 1).to_s }
245
+ ----
232
246
 
233
247
  [cols="1,1,3"]
234
248
  |===
@@ -641,6 +655,21 @@ store = Lutaml::Store.new(
641
655
  Persistent file-based storage with SHA-256 integrity checks. Files organized
642
656
  by key in subdirectories.
643
657
 
658
+ * *File names.* Every byte outside `[A-Za-z0-9._-]` is percent-encoded, so
659
+ `ISO/19115` and `ISO:19115` get different files and `keys` returns the
660
+ original keys. A leading `.` is encoded too, so no file is hidden or outside
661
+ the root. A name longer than 200 bytes becomes `~` plus the SHA-256 of the
662
+ key; the `.meta` file beside it keeps the key. Files written by 0.3.0 (with
663
+ `_` in place of unsafe characters) are still read, and move to the new name
664
+ on the next write of that key.
665
+ * *Values.* A `String` is written as is. Any other value (for example the
666
+ Hash that `DatabaseStore` saves) is written as JSON and read back as JSON.
667
+ * *Locks.* Writes hold an exclusive `flock` on `<path>/.lock` and reads hold a
668
+ shared one. Data and `.meta` files are written to a unique temp file and
669
+ renamed into place, so readers never see a partial write.
670
+ * *Case-insensitive file systems* (the macOS default) can still merge keys that
671
+ differ only in letter case.
672
+
644
673
  === SQLite
645
674
 
646
675
  [source,ruby]
@@ -681,8 +710,20 @@ Error hierarchy:
681
710
 
682
711
  == Thread safety
683
712
 
684
- All adapters use mutex-based synchronization. Safe for concurrent use across
685
- threads.
713
+ * `Adapter::Memory`: writes are synchronized with a Monitor; reads use a
714
+ frozen snapshot.
715
+ * `Adapter::FileSystem`: safe across threads and processes that share one
716
+ directory (see the FileSystem section above). `transaction { ... }` holds the exclusive
717
+ lock for the whole block.
718
+ * `Adapter::Sqlite`: SQLite locking with a busy timeout; `update` uses
719
+ `BEGIN IMMEDIATE`.
720
+ * `CacheStore`: all operations run under one lock per instance. The TTL and
721
+ LRU bookkeeping is per process.
722
+ * `BasicStore`: the optional read cache is per process. Disable it
723
+ (`cache: { enabled: false }`) when other processes write the same store.
724
+
725
+ A `get` followed by a `set` is never atomic. Use `update` for a
726
+ read-modify-write.
686
727
 
687
728
  == Development
688
729
 
@@ -31,7 +31,9 @@ module Lutaml
31
31
  end
32
32
 
33
33
  def each_key(&block)
34
- raise NotImplementedError
34
+ return enum_for(:each_key) unless block
35
+
36
+ keys.each(&block)
35
37
  end
36
38
 
37
39
  def all
@@ -88,6 +90,17 @@ module Lutaml
88
90
  yield
89
91
  end
90
92
 
93
+ # Read-modify-write: yields the current value (nil when missing) and
94
+ # stores the block's result. Atomic only where the adapter's
95
+ # #transaction is; Memory, FileSystem and Sqlite override this.
96
+ def update(key)
97
+ transaction do
98
+ new_value = yield(get(key))
99
+ set(key, new_value)
100
+ new_value
101
+ end
102
+ end
103
+
91
104
  def stats
92
105
  { adapter: self.class.name }
93
106
  end
@@ -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)
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "monitor"
4
5
  require "time"
5
6
 
6
7
  module Lutaml
7
8
  module Store
8
9
  # TTL-aware cache store with LRU eviction. Wraps a storage adapter directly.
10
+ #
11
+ # All public operations run under one re-entrant lock, so the LRU and
12
+ # cleanup bookkeeping is safe across threads. Across processes only the
13
+ # adapter's own guarantees apply.
9
14
  class CacheStore
10
15
  class CacheEntry
11
16
  attr_reader :value, :created_at, :ttl, :metadata
@@ -58,66 +63,75 @@ module Lutaml
58
63
  @cleanup_interval = config[:cleanup_interval] || 300
59
64
  @last_cleanup = Time.now
60
65
  @access_times = {}
66
+ @lock = ::Monitor.new
61
67
  end
62
68
 
63
69
  def get(key)
64
- cleanup_expired if should_cleanup?
70
+ @lock.synchronize do
71
+ cleanup_if_due
65
72
 
66
- entry_data = @adapter.get(key)
67
- return nil unless entry_data
73
+ entry_data = @adapter.get(key)
74
+ next nil unless entry_data
68
75
 
69
- begin
70
- entry = deserialize_entry(entry_data)
76
+ begin
77
+ entry = deserialize_entry(entry_data)
78
+
79
+ if entry.expired?
80
+ delete(key)
81
+ next nil
82
+ end
71
83
 
72
- if entry.expired?
84
+ @access_times[key] = Time.now
85
+ entry.value
86
+ rescue StandardError
73
87
  delete(key)
74
- return nil
88
+ nil
75
89
  end
76
-
77
- @access_times[key] = Time.now
78
- entry.value
79
- rescue StandardError
80
- delete(key)
81
- nil
82
90
  end
83
91
  end
84
92
 
85
93
  def set(key, value, ttl: :default, metadata: {})
86
- cleanup_expired if should_cleanup?
87
- evict_if_needed
94
+ @lock.synchronize do
95
+ cleanup_if_due
96
+ evict_if_needed
88
97
 
89
- effective_ttl = ttl == :default ? @default_ttl : ttl
90
- entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
98
+ effective_ttl = ttl == :default ? @default_ttl : ttl
99
+ entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
91
100
 
92
- serialized_entry = serialize_entry(entry)
93
- @adapter.set(key, serialized_entry)
101
+ serialized_entry = serialize_entry(entry)
102
+ @adapter.set(key, serialized_entry)
94
103
 
95
- @access_times[key] = Time.now
96
- value
104
+ @access_times[key] = Time.now
105
+ value
106
+ end
97
107
  end
98
108
 
99
109
  def delete(key)
100
- value = nil
101
- entry_data = @adapter.get(key)
102
- if entry_data
103
- begin
104
- entry = deserialize_entry(entry_data)
105
- value = entry.value unless entry.expired?
106
- rescue StandardError
107
- # If we can't deserialize, treat as nil
110
+ @lock.synchronize do
111
+ value = nil
112
+ entry_data = @adapter.get(key)
113
+ if entry_data
114
+ begin
115
+ entry = deserialize_entry(entry_data)
116
+ value = entry.value unless entry.expired?
117
+ rescue StandardError
118
+ # If we can't deserialize, treat as nil
119
+ end
108
120
  end
109
- end
110
121
 
111
- @access_times.delete(key)
122
+ @access_times.delete(key)
112
123
 
113
- deleted = @adapter.delete(key)
124
+ deleted = @adapter.delete(key)
114
125
 
115
- deleted ? value : nil
126
+ deleted ? value : nil
127
+ end
116
128
  end
117
129
 
118
130
  def clear
119
- @access_times.clear
120
- @adapter.clear
131
+ @lock.synchronize do
132
+ @access_times.clear
133
+ @adapter.clear
134
+ end
121
135
  end
122
136
 
123
137
  def exists?(key)
@@ -135,12 +149,13 @@ module Lutaml
135
149
  end
136
150
 
137
151
  def keys
138
- cleanup_expired if should_cleanup?
139
- @adapter.keys.select { |key| exists?(key) }
152
+ @lock.synchronize do
153
+ cleanup_if_due
154
+ @adapter.keys.select { |key| exists?(key) }
155
+ end
140
156
  end
141
157
 
142
158
  def size
143
- cleanup_expired if should_cleanup?
144
159
  keys.size
145
160
  end
146
161
 
@@ -169,22 +184,7 @@ module Lutaml
169
184
  end
170
185
 
171
186
  def cleanup_expired
172
- expired_keys = []
173
-
174
- @adapter.each_key do |key|
175
- entry_data = @adapter.get(key)
176
- next unless entry_data
177
-
178
- entry = deserialize_entry(entry_data)
179
- expired_keys << key if entry.expired?
180
- rescue StandardError
181
- expired_keys << key
182
- end
183
-
184
- expired_keys.each { |key| delete(key) }
185
- @last_cleanup = Time.now
186
-
187
- expired_keys.size
187
+ @lock.synchronize { cleanup_expired_entries }
188
188
  end
189
189
 
190
190
  def cache_info
@@ -203,23 +203,25 @@ module Lutaml
203
203
  end
204
204
 
205
205
  def touch(key, ttl: nil)
206
- entry_data = @adapter.get(key)
207
- return false unless entry_data
206
+ @lock.synchronize do
207
+ entry_data = @adapter.get(key)
208
+ next false unless entry_data
208
209
 
209
- begin
210
- entry = deserialize_entry(entry_data)
211
- return false if entry.expired?
210
+ begin
211
+ entry = deserialize_entry(entry_data)
212
+ next false if entry.expired?
212
213
 
213
- new_ttl = ttl || entry.ttl
214
- new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
214
+ new_ttl = ttl || entry.ttl
215
+ new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
215
216
 
216
- serialized_entry = serialize_entry(new_entry)
217
- @adapter.set(key, serialized_entry)
217
+ serialized_entry = serialize_entry(new_entry)
218
+ @adapter.set(key, serialized_entry)
218
219
 
219
- @access_times[key] = Time.now
220
- true
221
- rescue StandardError
222
- false
220
+ @access_times[key] = Time.now
221
+ true
222
+ rescue StandardError
223
+ false
224
+ end
223
225
  end
224
226
  end
225
227
 
@@ -263,14 +265,45 @@ module Lutaml
263
265
  Time.now - @last_cleanup > @cleanup_interval
264
266
  end
265
267
 
268
+ # Caller holds @lock.
269
+ def cleanup_if_due
270
+ cleanup_expired_entries if should_cleanup?
271
+ end
272
+
273
+ # Caller holds @lock. @last_cleanup is set first, so a nested call
274
+ # (through #delete) does not start a second scan.
275
+ def cleanup_expired_entries
276
+ @last_cleanup = Time.now
277
+ expired_keys = []
278
+
279
+ @adapter.each_key do |key|
280
+ entry_data = @adapter.get(key)
281
+ next unless entry_data
282
+
283
+ entry = deserialize_entry(entry_data)
284
+ expired_keys << key if entry.expired?
285
+ rescue StandardError
286
+ expired_keys << key
287
+ end
288
+
289
+ expired_keys.each { |key| delete(key) }
290
+
291
+ expired_keys.size
292
+ end
293
+
294
+ # Caller holds @lock. Keys this process never touched count as the
295
+ # least recently used.
266
296
  def evict_if_needed
267
297
  return unless @max_size
268
- return if size < @max_size
269
298
 
270
- keys_by_access = @access_times.sort_by { |_, time| time }.map(&:first)
271
- keys_to_evict = keys_by_access.first(size - @max_size + 1)
299
+ stored_keys = @adapter.keys
300
+ overflow = stored_keys.size - @max_size + 1
301
+ return unless overflow.positive?
302
+
303
+ untouched = stored_keys.reject { |key| @access_times.key?(key) }
304
+ by_access = @access_times.sort_by { |_, time| time }.map(&:first)
272
305
 
273
- keys_to_evict.each { |key| delete(key) }
306
+ (untouched + by_access).first(overflow).each { |key| delete(key) }
274
307
  end
275
308
  end
276
309
  end
@@ -14,7 +14,7 @@ module Lutaml
14
14
  cache: {}, monitoring: {}, events: {},
15
15
  compression: {}, serialization: {}, **)
16
16
  @adapter_type = normalize_adapter_type(adapter_type)
17
- @adapter_options = symbolize_keys(adapter_options)
17
+ @adapter_options = inline_adapter_options(adapter_type).merge(symbolize_keys(adapter_options))
18
18
 
19
19
  cache_config = symbolize_keys(cache)
20
20
  @cache_enabled = cache_config.fetch(:enabled, true)
@@ -134,6 +134,18 @@ module Lutaml
134
134
  end
135
135
  end
136
136
 
137
+ # Options given inline with the type, as in
138
+ # `adapter: { type: :filesystem, path: "./data" }` or
139
+ # `adapter: { type: :sqlite, options: { path: "a.db" } }`.
140
+ def inline_adapter_options(adapter_type)
141
+ return {} unless adapter_type.is_a?(Hash)
142
+
143
+ inline = symbolize_keys(adapter_type)
144
+ nested = inline.delete(:options) || {}
145
+ inline.delete(:type)
146
+ inline.merge(nested)
147
+ end
148
+
137
149
  def validate_adapter_config
138
150
  valid_adapters = %i[memory filesystem sqlite]
139
151
  unless valid_adapters.include?(@adapter_type)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ronald Tse
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: lutaml-model
@@ -139,6 +140,7 @@ metadata:
139
140
  source_code_uri: https://github.com/lutaml/lutaml-store
140
141
  changelog_uri: https://github.com/lutaml/lutaml-store
141
142
  rubygems_mfa_required: 'true'
143
+ post_install_message:
142
144
  rdoc_options: []
143
145
  require_paths:
144
146
  - lib
@@ -153,7 +155,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
153
155
  - !ruby/object:Gem::Version
154
156
  version: '0'
155
157
  requirements: []
156
- rubygems_version: 4.0.16
158
+ rubygems_version: 3.5.22
159
+ signing_key:
157
160
  specification_version: 4
158
161
  summary: Store-centric database-style API for Lutaml::Model with multi-backend persistence.
159
162
  test_files: []