philiprehberger-cache_kit 0.2.1 → 0.3.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: b52d4fb6a336dbcb73a7fa0ff6c2586d175f8b8562e482f06f4e45ae5a0ffc30
4
- data.tar.gz: b5d3cc503b17c9946d0ee3458e42b16d59bdaf6a01adf93c173210f0828abfb3
3
+ metadata.gz: 12588884ddff5dd03ef08a73de323fbbdfe6c1a45c3c897d21345c3086ef2a52
4
+ data.tar.gz: 6379e4e269b302187fe80537570cf1df42fa189e63eb49af8fa8ca191f470fb5
5
5
  SHA512:
6
- metadata.gz: dba97258e96462833062a4df553b7c9f267547fb97c79da41db696e6f4723ca0bf85cbe9215e2b674f8d8c8c89f00977bd3e7901266a7d4e8d46e6d1593420e3
7
- data.tar.gz: 48625bfcb842d9eff02c0be6dd784860cfed993e3597880101dddc984d16584e00ea368a2a579de8be2eb9d1793eafe60315fe196931017e402922d17c4c8048
6
+ metadata.gz: 16bd7b9ff5e9db9739d744883f8cd90bd974af8dcd94be6b3bc794cff058b362a3d5028de40ad6f1ed67cc39dd1ed1b8f031e0ec233fcbbb3087b65369e1662c
7
+ data.tar.gz: 01c441fa9c88a967f21931aa253beeefeb39a048cbc7fcb8d455b8bc39bef62b58b7f2c7be6f191ee09fcef5ad0bf8974ca08f5c019c2d20dac7e5ba7708b42d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+ - Thread-safe `fetch` — compute-on-miss now holds the lock for the entire operation, preventing duplicate computation
7
+ - Eviction callbacks via `on_evict { |key, value| ... }` — fires on LRU eviction and TTL expiry
8
+ - Per-tag statistics via `stats(tag: :name)` — returns `{ hits:, misses:, evictions: }` for a specific tag
9
+ - Batch retrieval via `get_many(keys)` — fetch multiple keys in a single lock acquisition, returns a hash
10
+ - Snapshot and restore via `snapshot` / `restore(data)` — serialize and deserialize cache state for warm restarts
11
+
12
+ ## 0.2.2
13
+
14
+ - Add License badge to README
15
+ - Add bug_tracker_uri to gemspec
16
+
3
17
  All notable changes to this gem will be documented in this file.
4
18
 
5
19
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-cache-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-cache-kit/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-cache_kit.svg)](https://rubygems.org/gems/philiprehberger-cache_kit)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-cache-kit)](LICENSE)
5
6
 
6
7
  In-memory LRU cache with TTL, tags, and thread safety for Ruby.
7
8
 
@@ -41,11 +42,13 @@ cache.set("user:1", { name: "Alice" }, ttl: 300)
41
42
  cache.get("user:1") # => { name: "Alice" }
42
43
  ```
43
44
 
44
- ### Fetch (get or compute)
45
+ ### Fetch (compute-on-miss)
46
+
47
+ Thread-safe get-or-compute. The block is only called if the key is missing or expired, and the entire operation holds the lock to prevent duplicate computation.
45
48
 
46
49
  ```ruby
47
50
  user = cache.fetch("user:1", ttl: 300) do
48
- User.find(1)
51
+ User.find(1) # only called on cache miss
49
52
  end
50
53
  ```
51
54
 
@@ -68,6 +71,34 @@ cache.set("post:1", data3, tags: ["posts"])
68
71
  cache.invalidate_tag("users") # removes user:1 and user:2, keeps post:1
69
72
  ```
70
73
 
74
+ ### Eviction Callback
75
+
76
+ Register hooks that fire when entries are evicted by LRU pressure or TTL expiry.
77
+
78
+ ```ruby
79
+ cache.on_evict do |key, value|
80
+ logger.info("Evicted #{key}")
81
+ end
82
+
83
+ # Fires on LRU eviction
84
+ cache.set("overflow", "data") # triggers callback if cache is full
85
+
86
+ # Fires on TTL expiry (during get or prune)
87
+ cache.prune
88
+ ```
89
+
90
+ ### Batch Get
91
+
92
+ Retrieve multiple keys in a single lock acquisition. Returns a hash mapping each key to its value (or nil if missing/expired).
93
+
94
+ ```ruby
95
+ cache.set("a", 1)
96
+ cache.set("b", 2)
97
+
98
+ cache.get_many(["a", "b", "missing"])
99
+ # => { "a" => 1, "b" => 2, "missing" => nil }
100
+ ```
101
+
71
102
  ### Hash-like Access
72
103
 
73
104
  ```ruby
@@ -110,6 +141,36 @@ cache.stats
110
141
  # => { size: 1, hits: 1, misses: 1, evictions: 0 }
111
142
  ```
112
143
 
144
+ ### Stats by Tag
145
+
146
+ Track per-tag hit, miss, and eviction counters.
147
+
148
+ ```ruby
149
+ cache.set("user:1", data, tags: ["users"])
150
+ cache.set("post:1", data, tags: ["posts"])
151
+ cache.get("user:1") # hit on "users" tag
152
+
153
+ cache.stats(tag: "users")
154
+ # => { hits: 1, misses: 0, evictions: 0 }
155
+
156
+ cache.stats(tag: "posts")
157
+ # => { hits: 0, misses: 0, evictions: 0 }
158
+ ```
159
+
160
+ ### Snapshot and Restore
161
+
162
+ Serialize cache state for warm restarts. The snapshot captures all entries with their remaining TTL, tags, and LRU order.
163
+
164
+ ```ruby
165
+ # Save state before shutdown
166
+ data = cache.snapshot
167
+ File.write("cache.bin", Marshal.dump(data))
168
+
169
+ # Restore on startup
170
+ new_cache = Philiprehberger::CacheKit::Store.new(max_size: 500)
171
+ new_cache.restore(Marshal.load(File.read("cache.bin")))
172
+ ```
173
+
113
174
  ## API
114
175
 
115
176
  | Method | Description |
@@ -117,7 +178,7 @@ cache.stats
117
178
  | `Store.new(max_size: 1000)` | Create a cache with max entries |
118
179
  | `Store#get(key)` | Get a value (nil if missing/expired) |
119
180
  | `Store#set(key, value, ttl:, tags:)` | Store a value |
120
- | `Store#fetch(key, ttl:, tags:, &block)` | Get or compute a value |
181
+ | `Store#fetch(key, ttl:, tags:, &block)` | Get or compute a value (thread-safe) |
121
182
  | `Store#delete(key)` | Delete a key |
122
183
  | `Store#invalidate_tag(tag)` | Remove all entries with a tag |
123
184
  | `Store#clear` | Remove all entries |
@@ -127,7 +188,12 @@ cache.stats
127
188
  | `Store#[](key)` | Hash-like read (alias for `get`) |
128
189
  | `Store#[]=(key, value)` | Hash-like write (alias for `set` without TTL/tags) |
129
190
  | `Store#stats` | Returns `{ size:, hits:, misses:, evictions: }` |
191
+ | `Store#stats(tag: name)` | Returns `{ hits:, misses:, evictions: }` for a tag |
130
192
  | `Store#prune` | Remove all expired entries, returns count removed |
193
+ | `Store#on_evict { \|key, value\| }` | Register eviction callback |
194
+ | `Store#get_many(keys)` | Batch get, returns `{ key => value }` hash |
195
+ | `Store#snapshot` | Serialize cache state to a hash |
196
+ | `Store#restore(data)` | Restore cache state from a snapshot |
131
197
 
132
198
  ## Development
133
199
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CacheKit
5
+ # Batch operations for Store.
6
+ module Batch
7
+ # Retrieve multiple keys in a single lock acquisition.
8
+ #
9
+ # @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)
12
+ @mutex.synchronize do
13
+ keys.to_h do |key|
14
+ [key, fetch_entry(key)]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CacheKit
5
+ # Eviction callback support for Store.
6
+ module Callbacks
7
+ # Register a callback invoked when entries are evicted.
8
+ #
9
+ # @yield [key, value] called on eviction (LRU or TTL expiry)
10
+ # @return [void]
11
+ def on_evict(&block)
12
+ @mutex.synchronize { @evict_callbacks << block }
13
+ end
14
+
15
+ private
16
+
17
+ def init_callbacks
18
+ @evict_callbacks = []
19
+ end
20
+
21
+ def fire_evict(key, value)
22
+ @evict_callbacks.each { |cb| cb.call(key, value) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CacheKit
5
+ # Internal eviction and entry management for Store.
6
+ module Eviction
7
+ private
8
+
9
+ def touch(key)
10
+ @order.delete(key)
11
+ @order.push(key)
12
+ end
13
+
14
+ def fetch_entry(key)
15
+ entry = @data[key]
16
+ return record_miss(key) unless entry
17
+ return expire_and_miss(key) if entry.expired?
18
+
19
+ @hits += 1
20
+ record_tag_hit(entry)
21
+ touch(key)
22
+ entry.value
23
+ end
24
+
25
+ def record_miss(key)
26
+ @misses += 1
27
+ record_tag_miss_for(key)
28
+ nil
29
+ end
30
+
31
+ def expire_and_miss(key)
32
+ evict_entry(key)
33
+ @misses += 1
34
+ nil
35
+ end
36
+
37
+ def evict
38
+ oldest = @order.first
39
+ return unless oldest
40
+
41
+ evict_entry(oldest)
42
+ end
43
+
44
+ def evict_entry(key)
45
+ entry = @data[key]
46
+ return unless entry
47
+
48
+ fire_evict(key, entry.value)
49
+ record_tag_eviction(entry)
50
+ @evictions += 1
51
+ remove_entry(key)
52
+ end
53
+
54
+ def remove_entry(key)
55
+ @data.delete(key)
56
+ @order.delete(key)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CacheKit
5
+ # Snapshot and restore support for Store.
6
+ module Serializable
7
+ # Serialize current cache state for persistence.
8
+ #
9
+ # @return [Hash] serialized cache data
10
+ def snapshot
11
+ @mutex.synchronize { build_snapshot }
12
+ end
13
+
14
+ # Restore cache state from a previous snapshot.
15
+ #
16
+ # @param data [Hash] snapshot data from #snapshot
17
+ # @return [void]
18
+ def restore(data)
19
+ @mutex.synchronize { apply_snapshot(data) }
20
+ end
21
+
22
+ private
23
+
24
+ def build_snapshot
25
+ entries = @data.transform_values { |e| serialize_entry(e) }
26
+ { entries: entries, order: @order.dup }
27
+ end
28
+
29
+ def serialize_entry(entry)
30
+ { value: entry.value, ttl: remaining_ttl(entry), tags: entry.tags }
31
+ end
32
+
33
+ def remaining_ttl(entry)
34
+ original = entry.instance_variable_get(:@ttl)
35
+ return nil if original.nil?
36
+
37
+ remaining = original - (Time.now - entry.created_at)
38
+ remaining.positive? ? remaining : 0
39
+ end
40
+
41
+ def apply_snapshot(data)
42
+ @data.clear
43
+ @order.clear
44
+ restore_entries(data[:entries])
45
+ @order.replace(data[:order].select { |k| @data.key?(k) })
46
+ end
47
+
48
+ def restore_entries(entries)
49
+ entries.each do |key, attrs|
50
+ next if attrs[:ttl]&.zero?
51
+
52
+ @data[key] = Entry.new(attrs[:value], ttl: attrs[:ttl], tags: attrs[:tags] || [])
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -4,6 +4,12 @@ module Philiprehberger
4
4
  module CacheKit
5
5
  # Thread-safe in-memory LRU cache with TTL and tag-based invalidation.
6
6
  class Store
7
+ include Callbacks
8
+ include TagStats
9
+ include Batch
10
+ include Serializable
11
+ include Eviction
12
+
7
13
  # @param max_size [Integer] maximum number of entries (LRU eviction when exceeded)
8
14
  def initialize(max_size: 1000)
9
15
  @max_size = max_size
@@ -13,151 +19,109 @@ module Philiprehberger
13
19
  @hits = 0
14
20
  @misses = 0
15
21
  @evictions = 0
22
+ init_callbacks
23
+ init_tag_stats
16
24
  end
17
25
 
18
26
  # Get a value by key. Returns nil if missing or expired.
19
- #
20
- # @param key [String] the cache key
21
- # @return the cached value, or nil
22
27
  def get(key)
23
28
  @mutex.synchronize { fetch_entry(key) }
24
29
  end
25
30
 
26
- # Store a value.
27
- #
28
- # @param key [String] the cache key
29
- # @param value the value to cache
30
- # @param ttl [Numeric, nil] time-to-live in seconds
31
- # @param tags [Array<String>] tags for bulk invalidation
32
- # @return the stored value
31
+ # Store a value with optional TTL and tags.
33
32
  def set(key, value, ttl: nil, tags: [])
34
- @mutex.synchronize do
35
- remove_entry(key) if @data.key?(key)
36
- evict if @data.size >= @max_size
37
-
38
- @data[key] = Entry.new(value, ttl: ttl, tags: tags)
39
- @order.push(key)
40
- value
41
- end
42
- end
43
-
44
- # Get or compute a value.
45
- #
46
- # @param key [String] the cache key
47
- # @param ttl [Numeric, nil] TTL for newly computed values
48
- # @param tags [Array<String>] tags for newly computed values
49
- # @yield computes the value if not cached
50
- # @return the cached or computed value
51
- def fetch(key, ttl: nil, tags: [], &block)
52
- value = get(key)
53
- return value unless value.nil?
33
+ @mutex.synchronize { store_entry(key, value, ttl: ttl, tags: tags) }
34
+ end
54
35
 
55
- computed = block.call
56
- set(key, computed, ttl: ttl, tags: tags)
57
- computed
36
+ # Get or compute a value (thread-safe).
37
+ def fetch(key, ttl: nil, tags: [], &block)
38
+ @mutex.synchronize { fetch_or_compute(key, ttl: ttl, tags: tags, &block) }
58
39
  end
59
40
 
60
41
  # @return [Boolean] true if the key existed
61
42
  def delete(key)
62
- @mutex.synchronize { @data.key?(key).tap { remove_entry(key) } }
43
+ @mutex.synchronize { delete_entry(key) }
63
44
  end
64
45
 
65
46
  # Invalidate all entries with a given tag.
66
- #
67
- # @param tag [String] the tag to invalidate
68
- # @return [Integer] number of entries removed
69
47
  def invalidate_tag(tag)
70
- @mutex.synchronize do
71
- tag_s = tag.to_s
72
- keys = @data.select { |_, entry| entry.tags.include?(tag_s) }.keys
73
- keys.each { |k| remove_entry(k) }
74
- keys.size
75
- end
48
+ @mutex.synchronize { invalidate_by_tag(tag) }
76
49
  end
77
50
 
78
- # @return [void]
79
51
  def clear
80
52
  @mutex.synchronize { @data.clear && @order.clear }
81
53
  end
82
54
 
83
- # @return [Integer] number of entries (including expired ones not yet evicted)
84
55
  def size
85
56
  @mutex.synchronize { @data.size }
86
57
  end
87
58
 
88
- # @return [Boolean] true if the key exists and is not expired
89
59
  def key?(key)
90
- @mutex.synchronize do
91
- entry = @data[key]
92
- entry ? !entry.expired? : false
93
- end
60
+ @mutex.synchronize { key_present?(key) }
94
61
  end
95
62
 
96
- # @return [Array<String>] list of valid keys
97
63
  def keys
98
- @mutex.synchronize { @data.reject { |_, entry| entry.expired? }.keys }
64
+ @mutex.synchronize { @data.reject { |_, e| e.expired? }.keys }
99
65
  end
100
66
 
101
- # @param key [String]
102
67
  def [](key) = get(key)
103
68
 
104
- # @param key [String]
105
- # @param value the value to cache
106
69
  def []=(key, value)
107
70
  set(key, value)
108
71
  end
109
72
 
110
- # @return [Hash] stats with :size, :hits, :misses, :evictions
111
- def stats
112
- @mutex.synchronize { { size: @data.size, hits: @hits, misses: @misses, evictions: @evictions } }
73
+ # @return [Hash] cache statistics, optionally filtered by tag
74
+ def stats(tag: nil)
75
+ @mutex.synchronize { tag ? tag_stats_for(tag) : global_stats }
113
76
  end
114
77
 
115
- # @return [Integer] number of entries removed
116
78
  def prune
117
- @mutex.synchronize do
118
- expired_keys = @data.select { |_, entry| entry.expired? }.keys
119
- expired_keys.each { |k| remove_entry(k) }
120
- expired_keys.size
121
- end
79
+ @mutex.synchronize { prune_expired }
122
80
  end
123
81
 
124
82
  private
125
83
 
126
- def touch(key)
127
- @order.delete(key)
128
- @order.push(key)
84
+ def global_stats
85
+ { size: @data.size, hits: @hits, misses: @misses, evictions: @evictions }
129
86
  end
130
87
 
131
- def fetch_entry(key)
132
- entry = @data[key]
133
- return record_miss unless entry
88
+ def store_entry(key, value, ttl: nil, tags: [])
89
+ remove_entry(key) if @data.key?(key)
90
+ evict if @data.size >= @max_size
91
+ @data[key] = Entry.new(value, ttl: ttl, tags: tags)
92
+ @order.push(key)
93
+ value
94
+ end
134
95
 
135
- if entry.expired?
136
- remove_entry(key)
137
- return record_miss
138
- end
96
+ def fetch_or_compute(key, ttl: nil, tags: [], &block)
97
+ value = fetch_entry(key)
98
+ return value unless value.nil?
139
99
 
140
- @hits += 1
141
- touch(key)
142
- entry.value
100
+ computed = block.call
101
+ store_entry(key, computed, ttl: ttl, tags: tags)
102
+ computed
143
103
  end
144
104
 
145
- def record_miss
146
- @misses += 1
147
- nil
105
+ def delete_entry(key)
106
+ @data.key?(key).tap { remove_entry(key) }
148
107
  end
149
108
 
150
- def evict
151
- oldest = @order.first
152
- return unless oldest
109
+ def invalidate_by_tag(tag)
110
+ tag_s = tag.to_s
111
+ matched = @data.select { |_, e| e.tags.include?(tag_s) }.keys
112
+ matched.each { |k| remove_entry(k) }
113
+ matched.size
114
+ end
153
115
 
154
- remove_entry(oldest)
155
- @evictions += 1
116
+ def key_present?(key)
117
+ entry = @data[key]
118
+ entry ? !entry.expired? : false
156
119
  end
157
120
 
158
- def remove_entry(key)
159
- @data.delete(key)
160
- @order.delete(key)
121
+ def prune_expired
122
+ expired = @data.select { |_, e| e.expired? }.keys
123
+ expired.each { |k| evict_entry(k) }
124
+ expired.size
161
125
  end
162
126
  end
163
127
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CacheKit
5
+ # Per-tag hit/miss/eviction tracking for Store.
6
+ module TagStats
7
+ private
8
+
9
+ def init_tag_stats
10
+ @tag_hits = Hash.new(0)
11
+ @tag_misses = Hash.new(0)
12
+ @tag_evictions = Hash.new(0)
13
+ end
14
+
15
+ def record_tag_hit(entry)
16
+ entry.tags.each { |t| @tag_hits[t] += 1 }
17
+ end
18
+
19
+ def record_tag_miss_for(key)
20
+ find_tags_for_key(key).each { |t| @tag_misses[t] += 1 }
21
+ end
22
+
23
+ def record_tag_eviction(entry)
24
+ entry.tags.each { |t| @tag_evictions[t] += 1 }
25
+ end
26
+
27
+ def find_tags_for_key(key)
28
+ entry = @data[key]
29
+ entry ? entry.tags : []
30
+ end
31
+
32
+ def tag_stats_for(tag)
33
+ tag_s = tag.to_s
34
+ {
35
+ hits: @tag_hits[tag_s],
36
+ misses: @tag_misses[tag_s],
37
+ evictions: @tag_evictions[tag_s]
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CacheKit
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -2,6 +2,11 @@
2
2
 
3
3
  require_relative "cache_kit/version"
4
4
  require_relative "cache_kit/entry"
5
+ require_relative "cache_kit/callbacks"
6
+ require_relative "cache_kit/tag_stats"
7
+ require_relative "cache_kit/batch"
8
+ require_relative "cache_kit/serializable"
9
+ require_relative "cache_kit/eviction"
5
10
  require_relative "cache_kit/store"
6
11
 
7
12
  module Philiprehberger
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.2.1
4
+ version: 0.3.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-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-17 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.
@@ -22,8 +22,13 @@ files:
22
22
  - LICENSE
23
23
  - README.md
24
24
  - lib/philiprehberger/cache_kit.rb
25
+ - lib/philiprehberger/cache_kit/batch.rb
26
+ - lib/philiprehberger/cache_kit/callbacks.rb
25
27
  - lib/philiprehberger/cache_kit/entry.rb
28
+ - lib/philiprehberger/cache_kit/eviction.rb
29
+ - lib/philiprehberger/cache_kit/serializable.rb
26
30
  - lib/philiprehberger/cache_kit/store.rb
31
+ - lib/philiprehberger/cache_kit/tag_stats.rb
27
32
  - lib/philiprehberger/cache_kit/version.rb
28
33
  homepage: https://github.com/philiprehberger/rb-cache-kit
29
34
  licenses: