lutaml-store 0.3.2 → 0.3.4
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/docs/src/pages/stores.md +22 -0
- data/lib/lutaml/store/adapter/filesystem.rb +28 -3
- data/lib/lutaml/store/adapter/memory.rb +34 -10
- data/lib/lutaml/store/adapter/sqlite.rb +49 -26
- data/lib/lutaml/store/cache_store.rb +4 -2
- data/lib/lutaml/store/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e80cbd1af20bbb11719de55936da8916731e8984b6db23a37ff87938339da5e
|
|
4
|
+
data.tar.gz: cb8491705513e1a6bff912033a97f9b9bde3f4a7fc32923691f47bcf5f81a94f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a7c4cb9de5eac681e24d45ad3df485e8dbe0edc7f3c43cd9f3e613177f3f7233426603a0452752d9d42d1ef54b9540e0f93e1bd956e765aed45c6762f36d367
|
|
7
|
+
data.tar.gz: a8b684093cedacacd4a8bea265eef3b4d11f5de0ce0b8f9dc256b881212f1a6f843f85dbae27fbc6da283858c3a8634641536e4cf383c51be40de848862d312a
|
data/docs/src/pages/stores.md
CHANGED
|
@@ -76,6 +76,28 @@ cache.fetch("key") { expensive_call } # block runs only on miss
|
|
|
76
76
|
|
|
77
77
|
Entries past their TTL are evicted lazily on access; when `max_size` is exceeded the least-recently-used entries are evicted first.
|
|
78
78
|
|
|
79
|
+
### TTL guarantees (pinned)
|
|
80
|
+
|
|
81
|
+
The TTL semantics are pinned by specs and safe to build expiry policy on:
|
|
82
|
+
|
|
83
|
+
- **Persistence** — entries serialize `created_at` as ISO-8601 with
|
|
84
|
+
microsecond precision, so a FileSystem (or SQLite) cache reopened in a new
|
|
85
|
+
process keeps every entry's original clock; expiry survives restarts.
|
|
86
|
+
The memory adapter keeps no state across processes by nature.
|
|
87
|
+
- **Clock** — wall clock, not monotonic. This is the deliberate trade:
|
|
88
|
+
wall-clock timestamps survive restarts, but a system clock jump changes
|
|
89
|
+
apparent freshness (a backward jump un-expires nothing an entry's absolute
|
|
90
|
+
`created_at + ttl` already expired). Consumers needing jump-immunity
|
|
91
|
+
re-derive freshness from the source.
|
|
92
|
+
- **Expired reads** — `get` on an expired entry returns `nil` **and deletes
|
|
93
|
+
the entry**: the key disappears from `keys`/`size`/`exists?`.
|
|
94
|
+
- **Sweep** — expiry is lazy-on-read plus an opportunistic
|
|
95
|
+
`cleanup_expired` (interval `cleanup_interval`, default 300 s) invoked
|
|
96
|
+
inside mutating reads. There is **no background reaper**; a consumer like
|
|
97
|
+
the relaton shard cache needs none.
|
|
98
|
+
- **TTL values** — `ttl: nil` (and no `default_ttl`) never expires;
|
|
99
|
+
`ttl: 0` is immediately expired.
|
|
100
|
+
|
|
79
101
|
## PackageStore — multi-model packages
|
|
80
102
|
|
|
81
103
|
```ruby
|
|
@@ -115,11 +115,32 @@ module Lutaml
|
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
# Reads every entry under one shared lock instead of taking a
|
|
119
|
+
# fresh flock per key; corrupt entries are repaired after the
|
|
120
|
+
# shared lock releases, preserving #get's contract for them
|
|
118
121
|
def all
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
result = {}
|
|
123
|
+
corrupt = []
|
|
124
|
+
|
|
125
|
+
with_lock(:sh) do
|
|
126
|
+
data_files.each do |file_path|
|
|
127
|
+
key = key_from_path(file_path)
|
|
128
|
+
next unless key
|
|
129
|
+
|
|
130
|
+
status, value = read_verified_at(file_path, key)
|
|
131
|
+
if status == :corrupt
|
|
132
|
+
corrupt << [key, value]
|
|
133
|
+
else
|
|
134
|
+
result[key] = value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
122
137
|
end
|
|
138
|
+
|
|
139
|
+
corrupt.each do |key, error|
|
|
140
|
+
result[key] = repair_corruption(key) || raise(error)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result
|
|
123
144
|
end
|
|
124
145
|
|
|
125
146
|
def clear
|
|
@@ -257,6 +278,10 @@ module Lutaml
|
|
|
257
278
|
file_path = existing_path_for_key(key)
|
|
258
279
|
return [:missing, nil] unless file_path
|
|
259
280
|
|
|
281
|
+
read_verified_at(file_path, key)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def read_verified_at(file_path, _key)
|
|
260
285
|
data = file_safe_read(file_path)
|
|
261
286
|
metadata = read_metadata(metadata_path_for(file_path))
|
|
262
287
|
|
|
@@ -19,6 +19,9 @@ module Lutaml
|
|
|
19
19
|
@ttl_data = @ttl_enabled ? {} : nil
|
|
20
20
|
@default_ttl = @config[:default_ttl] || 3600
|
|
21
21
|
@max_entries = @config[:max_entries]
|
|
22
|
+
# Earliest live expiry; TTL cleanup skips its scan while now
|
|
23
|
+
# is before this, so N reads don't cost N full-table scans
|
|
24
|
+
@next_expiry = nil
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
# ── Read operations (lock-free via snapshot) ──
|
|
@@ -71,7 +74,11 @@ module Lutaml
|
|
|
71
74
|
|
|
72
75
|
if @ttl_enabled
|
|
73
76
|
ttl_value = metadata[:ttl] || @default_ttl
|
|
74
|
-
|
|
77
|
+
if ttl_value
|
|
78
|
+
expires_at = Time.now + ttl_value
|
|
79
|
+
@ttl_data[key] = expires_at
|
|
80
|
+
track_expiry(expires_at)
|
|
81
|
+
end
|
|
75
82
|
end
|
|
76
83
|
end
|
|
77
84
|
value
|
|
@@ -104,6 +111,7 @@ module Lutaml
|
|
|
104
111
|
count = @data.size
|
|
105
112
|
@data.clear
|
|
106
113
|
@ttl_data&.clear
|
|
114
|
+
@next_expiry = nil
|
|
107
115
|
invalidate_snapshot
|
|
108
116
|
count
|
|
109
117
|
end
|
|
@@ -113,6 +121,7 @@ module Lutaml
|
|
|
113
121
|
@write_mutex.synchronize do
|
|
114
122
|
@data.clear
|
|
115
123
|
@ttl_data&.clear
|
|
124
|
+
@next_expiry = nil
|
|
116
125
|
invalidate_snapshot
|
|
117
126
|
end
|
|
118
127
|
end
|
|
@@ -123,10 +132,14 @@ module Lutaml
|
|
|
123
132
|
evict_oldest_if_needed(key)
|
|
124
133
|
@data[key] = value
|
|
125
134
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
next unless @ttl_enabled
|
|
136
|
+
|
|
137
|
+
ttl_value = ttl || @default_ttl
|
|
138
|
+
next unless ttl_value
|
|
139
|
+
|
|
140
|
+
expires_at = Time.now + ttl_value
|
|
141
|
+
@ttl_data[key] = expires_at
|
|
142
|
+
track_expiry(expires_at)
|
|
130
143
|
end
|
|
131
144
|
invalidate_snapshot
|
|
132
145
|
end
|
|
@@ -159,6 +172,7 @@ module Lutaml
|
|
|
159
172
|
@data.delete(key)
|
|
160
173
|
@ttl_data.delete(key)
|
|
161
174
|
end
|
|
175
|
+
recompute_next_expiry unless expired_keys.empty?
|
|
162
176
|
invalidate_snapshot unless expired_keys.empty?
|
|
163
177
|
expired_keys.size
|
|
164
178
|
end
|
|
@@ -171,7 +185,9 @@ module Lutaml
|
|
|
171
185
|
return false unless @data.key?(key)
|
|
172
186
|
|
|
173
187
|
if ttl
|
|
174
|
-
|
|
188
|
+
expires_at = Time.now + ttl
|
|
189
|
+
@ttl_data[key] = expires_at
|
|
190
|
+
track_expiry(expires_at)
|
|
175
191
|
else
|
|
176
192
|
@ttl_data.delete(key)
|
|
177
193
|
end
|
|
@@ -283,13 +299,22 @@ module Lutaml
|
|
|
283
299
|
@snapshot_stale = true
|
|
284
300
|
end
|
|
285
301
|
|
|
302
|
+
def track_expiry(expires_at)
|
|
303
|
+
@next_expiry = expires_at if @next_expiry.nil? || expires_at < @next_expiry
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Full-table scan; recomputes the watermark afterwards
|
|
307
|
+
def recompute_next_expiry
|
|
308
|
+
@next_expiry = @ttl_data.values.min
|
|
309
|
+
end
|
|
310
|
+
|
|
286
311
|
# Capacity bound (max_entries): evict the oldest-INSERTED entry to
|
|
287
312
|
# make room. Insertion order, not access order — refreshing on read
|
|
288
313
|
# would cost the lock-free snapshot reads. Updating an existing key
|
|
289
314
|
# never evicts.
|
|
290
315
|
def evict_oldest_if_needed(incoming_key)
|
|
291
316
|
return if @max_entries.nil? || @data.key?(incoming_key) ||
|
|
292
|
-
|
|
317
|
+
@data.size < @max_entries
|
|
293
318
|
|
|
294
319
|
oldest = @data.each_key.first
|
|
295
320
|
@data.delete(oldest)
|
|
@@ -298,9 +323,8 @@ module Lutaml
|
|
|
298
323
|
|
|
299
324
|
def cleanup_expired_if_needed
|
|
300
325
|
return unless @ttl_enabled
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return unless @ttl_data.any? { |_k, expiry| now > expiry }
|
|
326
|
+
return if @ttl_data.empty?
|
|
327
|
+
return if @next_expiry && Time.now < @next_expiry
|
|
304
328
|
|
|
305
329
|
cleanup_expired
|
|
306
330
|
end
|
|
@@ -26,26 +26,26 @@ module Lutaml
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# ── Key-value operations ──
|
|
29
|
+
#
|
|
30
|
+
# Non-string values are serialized as JSON and marked in the
|
|
31
|
+
# is_json column; strings are stored verbatim and returned
|
|
32
|
+
# verbatim. Without the marker, a stored string that happens to
|
|
33
|
+
# look like JSON (e.g. '{"x": 5}') decodes as an object.
|
|
29
34
|
|
|
30
35
|
def get(key)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
result = JSON.parse(value)
|
|
36
|
-
rescue JSON::ParserError
|
|
37
|
-
result = value
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
result
|
|
36
|
+
row = query_row("SELECT value, is_json FROM #{@table_name} WHERE key = ?", [key])
|
|
37
|
+
return nil unless row
|
|
38
|
+
|
|
39
|
+
decode_value(row[0], row[1])
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
def set(key, value)
|
|
44
|
-
|
|
43
|
+
is_string = value.is_a?(String)
|
|
44
|
+
serialized_value = is_string ? value : JSON.generate(value)
|
|
45
45
|
|
|
46
46
|
execute_statement(
|
|
47
|
-
"INSERT OR REPLACE INTO #{@table_name} (key, value, updated_at) VALUES (?, ?, ?)",
|
|
48
|
-
[key, serialized_value, Time.now.to_f]
|
|
47
|
+
"INSERT OR REPLACE INTO #{@table_name} (key, value, is_json, updated_at) VALUES (?, ?, ?, ?)",
|
|
48
|
+
[key, serialized_value, is_string ? 0 : 1, Time.now.to_f]
|
|
49
49
|
)
|
|
50
50
|
value
|
|
51
51
|
end
|
|
@@ -67,8 +67,8 @@ module Lutaml
|
|
|
67
67
|
|
|
68
68
|
def all
|
|
69
69
|
result = {}
|
|
70
|
-
execute_query_raw("SELECT key, value FROM #{@table_name}") do |row|
|
|
71
|
-
result[row[0]] = row[1]
|
|
70
|
+
execute_query_raw("SELECT key, value, is_json FROM #{@table_name}") do |row|
|
|
71
|
+
result[row[0]] = decode_value(row[1], row[2])
|
|
72
72
|
end
|
|
73
73
|
result
|
|
74
74
|
end
|
|
@@ -142,9 +142,7 @@ module Lutaml
|
|
|
142
142
|
sql, params = build_select_sql(query)
|
|
143
143
|
results = []
|
|
144
144
|
execute_query_raw(sql, params) do |row|
|
|
145
|
-
|
|
146
|
-
value = parse_json_value(row[1])
|
|
147
|
-
results << [key, value]
|
|
145
|
+
results << [row[0], decode_value(row[1], row[2])]
|
|
148
146
|
end
|
|
149
147
|
results
|
|
150
148
|
end
|
|
@@ -165,7 +163,7 @@ module Lutaml
|
|
|
165
163
|
params << after
|
|
166
164
|
end
|
|
167
165
|
|
|
168
|
-
sql = "SELECT key, value FROM #{@table_name}"
|
|
166
|
+
sql = "SELECT key, value, is_json FROM #{@table_name}"
|
|
169
167
|
sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
|
|
170
168
|
sql += " ORDER BY key ASC"
|
|
171
169
|
sql += " LIMIT ?"
|
|
@@ -173,9 +171,7 @@ module Lutaml
|
|
|
173
171
|
|
|
174
172
|
results = []
|
|
175
173
|
execute_query_raw(sql, params) do |row|
|
|
176
|
-
|
|
177
|
-
value = parse_json_value(row[1])
|
|
178
|
-
results << [key, value]
|
|
174
|
+
results << [row[0].to_s, decode_value(row[1], row[2])]
|
|
179
175
|
end
|
|
180
176
|
results
|
|
181
177
|
end
|
|
@@ -221,6 +217,7 @@ module Lutaml
|
|
|
221
217
|
@db.execute("PRAGMA temp_store=memory")
|
|
222
218
|
|
|
223
219
|
create_table_if_not_exists
|
|
220
|
+
migrate_schema
|
|
224
221
|
create_indexes
|
|
225
222
|
end
|
|
226
223
|
|
|
@@ -229,11 +226,24 @@ module Lutaml
|
|
|
229
226
|
CREATE TABLE IF NOT EXISTS #{@table_name} (
|
|
230
227
|
key TEXT PRIMARY KEY,
|
|
231
228
|
value BLOB NOT NULL,
|
|
229
|
+
is_json INTEGER NOT NULL DEFAULT 1,
|
|
232
230
|
updated_at REAL NOT NULL DEFAULT (julianday('now'))
|
|
233
231
|
)
|
|
234
232
|
SQL
|
|
235
233
|
end
|
|
236
234
|
|
|
235
|
+
# Databases created before the is_json column existed predate
|
|
236
|
+
# format tracking: legacy rows (default is_json = 1) decode as
|
|
237
|
+
# JSON when they parse, staying a string otherwise
|
|
238
|
+
def migrate_schema
|
|
239
|
+
columns = @db.execute("PRAGMA table_info(#{@table_name})").map do |row|
|
|
240
|
+
row[1]
|
|
241
|
+
end
|
|
242
|
+
return if columns.include?("is_json")
|
|
243
|
+
|
|
244
|
+
@db.execute("ALTER TABLE #{@table_name} ADD COLUMN is_json INTEGER NOT NULL DEFAULT 1")
|
|
245
|
+
end
|
|
246
|
+
|
|
237
247
|
def create_indexes
|
|
238
248
|
@db.execute("CREATE INDEX IF NOT EXISTS idx_#{@table_name}_updated_at ON #{@table_name} (updated_at)")
|
|
239
249
|
end
|
|
@@ -243,7 +253,7 @@ module Lutaml
|
|
|
243
253
|
def build_select_sql(query)
|
|
244
254
|
conditions, params = build_conditions(query)
|
|
245
255
|
|
|
246
|
-
sql = "SELECT key, value FROM #{@table_name}"
|
|
256
|
+
sql = "SELECT key, value, is_json FROM #{@table_name}"
|
|
247
257
|
sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
|
|
248
258
|
sql += build_order_clause(query.orders)
|
|
249
259
|
sql += build_limit_offset(query.limit_value, query.offset_value, params)
|
|
@@ -352,14 +362,27 @@ module Lutaml
|
|
|
352
362
|
"%#{pattern}%"
|
|
353
363
|
end
|
|
354
364
|
|
|
355
|
-
|
|
365
|
+
# ── Raw query helpers ──
|
|
366
|
+
|
|
367
|
+
def query_row(sql, params = [])
|
|
368
|
+
execute_query_raw(sql, params) do |row|
|
|
369
|
+
return row
|
|
370
|
+
end
|
|
371
|
+
nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# is_json marks rows written from non-string values (the
|
|
375
|
+
# default, including every pre-format-tracking row): they
|
|
376
|
+
# decode as JSON when they parse. Rows written from strings
|
|
377
|
+
# come back verbatim.
|
|
378
|
+
def decode_value(raw, is_json)
|
|
379
|
+
return raw unless is_json.to_i == 1
|
|
380
|
+
|
|
356
381
|
JSON.parse(raw)
|
|
357
382
|
rescue JSON::ParserError
|
|
358
383
|
raw
|
|
359
384
|
end
|
|
360
385
|
|
|
361
|
-
# ── Raw query helpers ──
|
|
362
|
-
|
|
363
386
|
def execute_query_raw(sql, params = [])
|
|
364
387
|
@db.execute(sql, params) do |row|
|
|
365
388
|
yield row if block_given?
|
|
@@ -36,10 +36,12 @@ module Lutaml
|
|
|
36
36
|
|
|
37
37
|
def to_h
|
|
38
38
|
{
|
|
39
|
+
# sub-second precision: a second-truncated created_at shortens
|
|
40
|
+
# every restored TTL by up to a second (matters at small TTLs)
|
|
39
41
|
value: @value,
|
|
40
|
-
created_at: @created_at.iso8601,
|
|
42
|
+
created_at: @created_at.iso8601(6),
|
|
41
43
|
ttl: @ttl,
|
|
42
|
-
expires_at: expires_at&.iso8601,
|
|
44
|
+
expires_at: expires_at&.iso8601(6),
|
|
43
45
|
metadata: @metadata
|
|
44
46
|
}
|
|
45
47
|
end
|
data/lib/lutaml/store/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +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.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ronald Tse
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.8.
|
|
19
|
+
version: 0.8.156
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.8.
|
|
26
|
+
version: 0.8.156
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: rubyzip
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|