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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75ef7dbba5f362a4a3370f2e389421bca6aeb29288b42060c9f13a497070a9ba
4
- data.tar.gz: caf151b0c9641bcddce86d10521b21b7746c99d2200bce020dbec3402edbdee5
3
+ metadata.gz: 867b798661eddfdd0c3904d4961570669cabca2ff6fb67c8a224570d86d098aa
4
+ data.tar.gz: c84d7c6fd9dd77522733db1ffafc3fce58248f3c41639a6729381170cbbf5bbd
5
5
  SHA512:
6
- metadata.gz: 2d6e3b0e24792d38afb21da30ccdb2b453a5e68ee96ccdab24115ab3bf67158f6ee139ed1e95748ea0a31f0a23df055d8352549502af3949cf315396aacb0465
7
- data.tar.gz: 5274211ebd71bac978295d49e35e80ecade8ed6696a90193761b89d2994b97c9e634858b5021d2a36b231fb34b4ee2a84f265583364793fd309be83f941a1561
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 mapping each key to its value (or nil if missing/expired).
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(["a", "b", "missing"])
93
- # => { "a" => 1, "b" => 2, "missing" => nil }
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 }` hash |
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 (missing/expired keys map to nil)
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
- keys.to_h do |key|
14
- [key, fetch_entry(key)]
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CacheKit
5
- VERSION = '0.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  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.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-01 00:00:00.000000000 Z
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.