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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3666640ef12a937789e1fa2357edbb7bb882468a78fac8053e8bcc0efbe94ccd
4
- data.tar.gz: da2a66ff3067ead5082ff16a6abea51232e49359a63b84e4cde45a3e42c768ac
3
+ metadata.gz: 1e80cbd1af20bbb11719de55936da8916731e8984b6db23a37ff87938339da5e
4
+ data.tar.gz: cb8491705513e1a6bff912033a97f9b9bde3f4a7fc32923691f47bcf5f81a94f
5
5
  SHA512:
6
- metadata.gz: 3d8eba2c1775fc8ac1fd170c4134be61046954212319ba63e5d6ca8ffeca845a5e3a808e49fded3668f6dba387d7f75b326a6b7821e17375f658a4ad76c92b75
7
- data.tar.gz: 75f6f23cbfd0f30cc58a7d21f45906878d260f07bf4b2201b84ca75493233d79406c71df1692353a7e263df962f5a34e498007ecc11c7a0ed46c2ffa89db4c97
6
+ metadata.gz: 8a7c4cb9de5eac681e24d45ad3df485e8dbe0edc7f3c43cd9f3e613177f3f7233426603a0452752d9d42d1ef54b9540e0f93e1bd956e765aed45c6762f36d367
7
+ data.tar.gz: a8b684093cedacacd4a8bea265eef3b4d11f5de0ce0b8f9dc256b881212f1a6f843f85dbae27fbc6da283858c3a8634641536e4cf383c51be40de848862d312a
@@ -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
- keys.each_with_object({}) do |key, result|
120
- value = get(key)
121
- result[key] = value unless value.nil?
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
- @ttl_data[key] = Time.now + ttl_value if ttl_value
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
- if @ttl_enabled
127
- ttl_value = ttl || @default_ttl
128
- @ttl_data[key] = Time.now + ttl_value if ttl_value
129
- end
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
- @ttl_data[key] = Time.now + ttl
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
- @data.size < @max_entries
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
- now = Time.now
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
- result = nil
32
- execute_query_raw("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
33
- value = row[0]
34
- begin
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
- serialized_value = value.is_a?(String) ? value : JSON.generate(value)
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
- key = row[0]
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
- key = row[0]
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
- def parse_json_value(raw)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.3.2"
5
+ VERSION = "0.3.4"
6
6
  end
7
7
  end
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.2
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-09-29 00:00:00.000000000 Z
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.15
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.15
26
+ version: 0.8.156
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rubyzip
29
29
  requirement: !ruby/object:Gem::Requirement