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 +4 -4
- data/CLAUDE.md +6 -0
- data/README.adoc +44 -3
- 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/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4481af10b7c80db97bffbe2e1ad5119d615370596c46c88c84e536411d1ffdda
|
|
4
|
+
data.tar.gz: 618ad367561533d0aa576ef713dfb384173e34ec1ec0047e3cbab400af9c4dce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
70
|
+
@lock.synchronize do
|
|
71
|
+
cleanup_if_due
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
entry_data = @adapter.get(key)
|
|
74
|
+
next nil unless entry_data
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
begin
|
|
77
|
+
entry = deserialize_entry(entry_data)
|
|
78
|
+
|
|
79
|
+
if entry.expired?
|
|
80
|
+
delete(key)
|
|
81
|
+
next nil
|
|
82
|
+
end
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
@access_times[key] = Time.now
|
|
85
|
+
entry.value
|
|
86
|
+
rescue StandardError
|
|
73
87
|
delete(key)
|
|
74
|
-
|
|
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
|
-
|
|
87
|
-
|
|
94
|
+
@lock.synchronize do
|
|
95
|
+
cleanup_if_due
|
|
96
|
+
evict_if_needed
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
effective_ttl = ttl == :default ? @default_ttl : ttl
|
|
99
|
+
entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
serialized_entry = serialize_entry(entry)
|
|
102
|
+
@adapter.set(key, serialized_entry)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
@access_times[key] = Time.now
|
|
105
|
+
value
|
|
106
|
+
end
|
|
97
107
|
end
|
|
98
108
|
|
|
99
109
|
def delete(key)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
122
|
+
@access_times.delete(key)
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
deleted = @adapter.delete(key)
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
deleted ? value : nil
|
|
127
|
+
end
|
|
116
128
|
end
|
|
117
129
|
|
|
118
130
|
def clear
|
|
119
|
-
@
|
|
120
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
206
|
+
@lock.synchronize do
|
|
207
|
+
entry_data = @adapter.get(key)
|
|
208
|
+
next false unless entry_data
|
|
208
209
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
begin
|
|
211
|
+
entry = deserialize_entry(entry_data)
|
|
212
|
+
next false if entry.expired?
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
new_ttl = ttl || entry.ttl
|
|
215
|
+
new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
|
|
215
216
|
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
serialized_entry = serialize_entry(new_entry)
|
|
218
|
+
@adapter.set(key, serialized_entry)
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
306
|
+
(untouched + by_access).first(overflow).each { |key| delete(key) }
|
|
274
307
|
end
|
|
275
308
|
end
|
|
276
309
|
end
|
data/lib/lutaml/store/config.rb
CHANGED
|
@@ -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)
|
data/lib/lutaml/store/version.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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: []
|