philiprehberger-cache_kit 0.4.0 → 0.6.0
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/CHANGELOG.md +22 -0
- data/README.md +66 -4
- data/lib/philiprehberger/cache_kit/batch.rb +9 -4
- data/lib/philiprehberger/cache_kit/entry.rb +21 -1
- data/lib/philiprehberger/cache_kit/store.rb +109 -0
- data/lib/philiprehberger/cache_kit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 867b798661eddfdd0c3904d4961570669cabca2ff6fb67c8a224570d86d098aa
|
|
4
|
+
data.tar.gz: c84d7c6fd9dd77522733db1ffafc3fce58248f3c41639a6729381170cbbf5bbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0073bbe9674271f29c49ece552ff99bc39a26baf2f89c3d2587420b152f1e62cb844468fff3c2c3fa45b02f4f16b72a9868f608f0a46cbc8c3d6d472d27f3948
|
|
7
|
+
data.tar.gz: 4cf8b12debfbfc89ec3fd3e9150538d1fcb6de4ebbfb7e1bd2a7872ea5a74a35304497c08ef3298a2f17e64bf1772563714673f96bc4fab0c9c7b592a277b3ca
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-04-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `#ttl(key)` returns remaining seconds until expiry (nil if no TTL, missing, or expired)
|
|
14
|
+
- `#expire_at(key)` returns absolute expiration `Time` (nil if no TTL, missing, or expired)
|
|
15
|
+
- `#delete_many(*keys)` bulk-deletes multiple keys in a single lock acquisition; returns count removed
|
|
16
|
+
- `#keys_by_tag(tag)` returns the keys associated with a tag (non-expired only)
|
|
17
|
+
- `#increment(key, by: 1, ttl: nil)` atomic numeric increment with optional TTL override
|
|
18
|
+
- `#decrement(key, by: 1, ttl: nil)` atomic numeric decrement
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Align `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml` with the latest issue-template guide (required fields, field order, reproduction/proposed-api placeholders)
|
|
22
|
+
|
|
23
|
+
## [0.5.0] - 2026-04-04
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `#get_many` now accepts splat args (`get_many(*keys)`) and skips misses, returning only found entries
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- `gem-version` field to bug report issue template
|
|
30
|
+
- `Alternatives considered` textarea to feature request issue template
|
|
31
|
+
|
|
10
32
|
## [0.4.0] - 2026-04-01
|
|
11
33
|
|
|
12
34
|
### Added
|
data/README.md
CHANGED
|
@@ -83,14 +83,14 @@ cache.prune
|
|
|
83
83
|
|
|
84
84
|
### Batch Get
|
|
85
85
|
|
|
86
|
-
Retrieve multiple keys in a single lock acquisition. Returns a hash
|
|
86
|
+
Retrieve multiple keys in a single lock acquisition. Returns a hash of found entries only, skipping misses.
|
|
87
87
|
|
|
88
88
|
```ruby
|
|
89
89
|
cache.set("a", 1)
|
|
90
90
|
cache.set("b", 2)
|
|
91
91
|
|
|
92
|
-
cache.get_many(
|
|
93
|
-
# => { "a" => 1, "b" => 2
|
|
92
|
+
cache.get_many("a", "b", "missing")
|
|
93
|
+
# => { "a" => 1, "b" => 2 }
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
### Bulk Set
|
|
@@ -169,6 +169,62 @@ cache.stats(tag: "posts")
|
|
|
169
169
|
# => { hits: 0, misses: 0, evictions: 0 }
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
+
### TTL Introspection
|
|
173
|
+
|
|
174
|
+
Read the remaining or absolute expiration without consuming the entry.
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
cache.set("session", "abc", ttl: 60)
|
|
178
|
+
|
|
179
|
+
cache.ttl("session") # => 59.87 (Float seconds)
|
|
180
|
+
cache.expire_at("session") # => 2026-04-14 14:41:23 +0000 (Time)
|
|
181
|
+
|
|
182
|
+
cache.set("permanent", "x")
|
|
183
|
+
cache.ttl("permanent") # => nil (no TTL)
|
|
184
|
+
cache.ttl("missing") # => nil
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Bulk Delete
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
cache.set("a", 1)
|
|
191
|
+
cache.set("b", 2)
|
|
192
|
+
cache.set("c", 3)
|
|
193
|
+
|
|
194
|
+
cache.delete_many("a", "b", "missing") # => 2 (count actually removed)
|
|
195
|
+
cache.keys # => ["c"]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Keys by Tag
|
|
199
|
+
|
|
200
|
+
Inspect which keys carry a tag without invalidating them.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
cache.set("user:1", data1, tags: ["users"])
|
|
204
|
+
cache.set("user:2", data2, tags: ["users"])
|
|
205
|
+
cache.set("post:1", data3, tags: ["posts"])
|
|
206
|
+
|
|
207
|
+
cache.keys_by_tag("users") # => ["user:1", "user:2"]
|
|
208
|
+
cache.keys_by_tag(:users) # => ["user:1", "user:2"]
|
|
209
|
+
cache.keys_by_tag("none") # => []
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Atomic Counters
|
|
213
|
+
|
|
214
|
+
Atomically increment and decrement numeric entries. Missing or expired keys
|
|
215
|
+
are initialized to 0 before the delta is applied.
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
cache.increment("views") # => 1
|
|
219
|
+
cache.increment("views") # => 2
|
|
220
|
+
cache.increment("views", by: 5) # => 7
|
|
221
|
+
cache.increment("views", ttl: 3600) # => 8 (and resets TTL to 3600s)
|
|
222
|
+
|
|
223
|
+
cache.decrement("quota", by: 2) # => -2 (if "quota" was missing)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Non-numeric values raise `Philiprehberger::CacheKit::Error`.
|
|
227
|
+
|
|
172
228
|
### Snapshot and Restore
|
|
173
229
|
|
|
174
230
|
Serialize cache state for warm restarts. The snapshot captures all entries with their remaining TTL, tags, and LRU order.
|
|
@@ -203,12 +259,18 @@ new_cache.restore(Marshal.load(File.read("cache.bin")))
|
|
|
203
259
|
| `Store#stats(tag: name)` | Returns `{ hits:, misses:, evictions: }` for a tag |
|
|
204
260
|
| `Store#prune` | Remove all expired entries, returns count removed |
|
|
205
261
|
| `Store#on_evict { \|key, value\| }` | Register eviction callback |
|
|
206
|
-
| `Store#get_many(keys)` | Batch get, returns `{ key => value }`
|
|
262
|
+
| `Store#get_many(*keys)` | Batch get, returns `{ key => value }` for found entries |
|
|
207
263
|
| `#set_many(hash, ttl:, tags:)` | Bulk set multiple entries |
|
|
208
264
|
| `#compact` | Prune expired entries, return eviction count |
|
|
209
265
|
| `#refresh(key, ttl:)` | Reset TTL without changing value |
|
|
210
266
|
| `Store#snapshot` | Serialize cache state to a hash |
|
|
211
267
|
| `Store#restore(data)` | Restore cache state from a snapshot |
|
|
268
|
+
| `Store#ttl(key)` | Remaining seconds until expiry (nil if none/missing/expired) |
|
|
269
|
+
| `Store#expire_at(key)` | Absolute expiration `Time` (nil if none/missing/expired) |
|
|
270
|
+
| `Store#delete_many(*keys)` | Bulk-delete, returns count removed |
|
|
271
|
+
| `Store#keys_by_tag(tag)` | Keys associated with a tag (non-expired) |
|
|
272
|
+
| `Store#increment(key, by:, ttl:)` | Atomic numeric increment |
|
|
273
|
+
| `Store#decrement(key, by:, ttl:)` | Atomic numeric decrement |
|
|
212
274
|
|
|
213
275
|
## Development
|
|
214
276
|
|
|
@@ -5,14 +5,19 @@ module Philiprehberger
|
|
|
5
5
|
# Batch operations for Store.
|
|
6
6
|
module Batch
|
|
7
7
|
# Retrieve multiple keys in a single lock acquisition.
|
|
8
|
+
# Returns only found (non-nil, non-expired) entries, skipping misses.
|
|
8
9
|
#
|
|
9
10
|
# @param keys [Array<String>] cache keys to retrieve
|
|
10
|
-
# @return [Hash] key => value pairs
|
|
11
|
-
def get_many(keys)
|
|
11
|
+
# @return [Hash] key => value pairs for found entries only
|
|
12
|
+
def get_many(*keys)
|
|
13
|
+
keys = keys.flatten
|
|
12
14
|
@mutex.synchronize do
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
result = {}
|
|
16
|
+
keys.each do |key|
|
|
17
|
+
value = fetch_entry(key)
|
|
18
|
+
result[key] = value unless value.nil?
|
|
15
19
|
end
|
|
20
|
+
result
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
end
|
|
@@ -4,7 +4,7 @@ module Philiprehberger
|
|
|
4
4
|
module CacheKit
|
|
5
5
|
# Internal cache entry with value, TTL, and tags.
|
|
6
6
|
class Entry
|
|
7
|
-
attr_reader :value, :tags, :created_at
|
|
7
|
+
attr_reader :value, :tags, :created_at, :ttl
|
|
8
8
|
|
|
9
9
|
# @param value the cached value
|
|
10
10
|
# @param ttl [Numeric, nil] time-to-live in seconds (nil = no expiry)
|
|
@@ -24,6 +24,26 @@ module Philiprehberger
|
|
|
24
24
|
|
|
25
25
|
(Time.now - @created_at) >= @ttl
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Absolute expiration time, or nil if the entry has no TTL.
|
|
29
|
+
#
|
|
30
|
+
# @return [Time, nil]
|
|
31
|
+
def expire_at
|
|
32
|
+
return nil if @ttl.nil?
|
|
33
|
+
|
|
34
|
+
@created_at + @ttl
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remaining seconds until expiry. Returns nil if no TTL is set,
|
|
38
|
+
# or 0.0 if the entry has already expired.
|
|
39
|
+
#
|
|
40
|
+
# @return [Float, nil]
|
|
41
|
+
def remaining_ttl
|
|
42
|
+
return nil if @ttl.nil?
|
|
43
|
+
|
|
44
|
+
remaining = @ttl - (Time.now - @created_at)
|
|
45
|
+
remaining.positive? ? remaining : 0.0
|
|
46
|
+
end
|
|
27
47
|
end
|
|
28
48
|
end
|
|
29
49
|
end
|
|
@@ -119,8 +119,117 @@ module Philiprehberger
|
|
|
119
119
|
end
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
# Remaining seconds until the entry expires.
|
|
123
|
+
#
|
|
124
|
+
# Returns nil when the key is missing, expired, or has no TTL.
|
|
125
|
+
#
|
|
126
|
+
# @param key [String] the cache key
|
|
127
|
+
# @return [Float, nil]
|
|
128
|
+
def ttl(key)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
entry = @data[key]
|
|
131
|
+
next nil if entry.nil? || entry.expired?
|
|
132
|
+
|
|
133
|
+
entry.remaining_ttl
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Absolute expiration time of the entry.
|
|
138
|
+
#
|
|
139
|
+
# Returns nil when the key is missing, expired, or has no TTL.
|
|
140
|
+
#
|
|
141
|
+
# @param key [String] the cache key
|
|
142
|
+
# @return [Time, nil]
|
|
143
|
+
def expire_at(key)
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
entry = @data[key]
|
|
146
|
+
next nil if entry.nil? || entry.expired?
|
|
147
|
+
|
|
148
|
+
entry.expire_at
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Bulk-delete multiple keys in a single lock acquisition.
|
|
153
|
+
# Does not fire eviction callbacks (matches #delete semantics).
|
|
154
|
+
#
|
|
155
|
+
# @param keys [Array<String>] keys to delete
|
|
156
|
+
# @return [Integer] number of keys that were actually removed
|
|
157
|
+
def delete_many(*keys)
|
|
158
|
+
keys = keys.flatten
|
|
159
|
+
@mutex.synchronize do
|
|
160
|
+
removed = 0
|
|
161
|
+
keys.each do |key|
|
|
162
|
+
next unless @data.key?(key)
|
|
163
|
+
|
|
164
|
+
remove_entry(key)
|
|
165
|
+
removed += 1
|
|
166
|
+
end
|
|
167
|
+
removed
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Return the keys associated with a given tag.
|
|
172
|
+
# Excludes expired entries.
|
|
173
|
+
#
|
|
174
|
+
# @param tag [String, Symbol]
|
|
175
|
+
# @return [Array<String>]
|
|
176
|
+
def keys_by_tag(tag)
|
|
177
|
+
tag_s = tag.to_s
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
@data.each_with_object([]) do |(key, entry), acc|
|
|
180
|
+
next if entry.expired?
|
|
181
|
+
|
|
182
|
+
acc << key if entry.tags.include?(tag_s)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Atomically increment a numeric entry. Initializes missing or
|
|
188
|
+
# expired keys to 0 before applying the delta.
|
|
189
|
+
#
|
|
190
|
+
# @param key [String] the cache key
|
|
191
|
+
# @param by [Numeric] amount to add (default 1)
|
|
192
|
+
# @param ttl [Numeric, nil] optional TTL override (nil preserves current TTL)
|
|
193
|
+
# @return [Numeric] the new value
|
|
194
|
+
# @raise [Error] when the existing value is not numeric
|
|
195
|
+
def increment(key, by: 1, ttl: nil)
|
|
196
|
+
@mutex.synchronize { apply_counter_delta(key, by, ttl: ttl) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Atomically decrement a numeric entry. See #increment.
|
|
200
|
+
#
|
|
201
|
+
# @param key [String] the cache key
|
|
202
|
+
# @param by [Numeric] amount to subtract (default 1)
|
|
203
|
+
# @param ttl [Numeric, nil] optional TTL override
|
|
204
|
+
# @return [Numeric] the new value
|
|
205
|
+
# @raise [Error] when the existing value is not numeric
|
|
206
|
+
def decrement(key, by: 1, ttl: nil)
|
|
207
|
+
@mutex.synchronize { apply_counter_delta(key, -by, ttl: ttl) }
|
|
208
|
+
end
|
|
209
|
+
|
|
122
210
|
private
|
|
123
211
|
|
|
212
|
+
def apply_counter_delta(key, delta, ttl: nil)
|
|
213
|
+
entry = @data[key]
|
|
214
|
+
current, preserved_tags, preserved_ttl = counter_state(entry)
|
|
215
|
+
raise Error, "value at #{key.inspect} is not numeric" unless current.is_a?(Numeric)
|
|
216
|
+
|
|
217
|
+
new_value = current + delta
|
|
218
|
+
remove_entry(key) if @data.key?(key)
|
|
219
|
+
effective_ttl = ttl.nil? ? preserved_ttl : ttl
|
|
220
|
+
@data[key] = Entry.new(new_value, ttl: effective_ttl, tags: preserved_tags)
|
|
221
|
+
@order.push(key)
|
|
222
|
+
new_value
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def counter_state(entry)
|
|
226
|
+
if entry.nil? || entry.expired?
|
|
227
|
+
[0, [], nil]
|
|
228
|
+
else
|
|
229
|
+
[entry.value, entry.tags, entry.ttl]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
124
233
|
def global_stats
|
|
125
234
|
{ size: @data.size, hits: @hits, misses: @misses, evictions: @evictions }
|
|
126
235
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-cache_kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A lightweight, thread-safe in-memory LRU cache with TTL expiration and
|
|
14
14
|
tag-based bulk invalidation for Ruby applications.
|