philiprehberger-cache_kit 0.2.2 → 0.3.1
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 +19 -5
- data/README.md +68 -9
- data/lib/philiprehberger/cache_kit/batch.rb +20 -0
- data/lib/philiprehberger/cache_kit/callbacks.rb +26 -0
- data/lib/philiprehberger/cache_kit/eviction.rb +60 -0
- data/lib/philiprehberger/cache_kit/serializable.rb +57 -0
- data/lib/philiprehberger/cache_kit/store.rb +52 -88
- data/lib/philiprehberger/cache_kit/tag_stats.rb +42 -0
- data/lib/philiprehberger/cache_kit/version.rb +1 -1
- data/lib/philiprehberger/cache_kit.rb +5 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b515b534e67af3974fd473015800fb3ca7ba58ed55d77039d26a8a20b43e1c7
|
|
4
|
+
data.tar.gz: c01e6bdee343e524f3abdc75585286a0bc75cc99c2f7fbfb61206e65ed5fb69c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 369ba8aa6cc57b595cec309c0a01bba8d95b1c88c6311d38951f225a96cc37c9925297573499ac54d6a05029e731afe17b3d393cff568a9a472497cd45045da3
|
|
7
|
+
data.tar.gz: 545cb36af1792619ce4cc440de72a5f1bd7814d39c5db0de751f33d2b99fe5296708d4cc8de87ef8be1cb7d2a4cfb56b88ccce845b9c09f84475e76b50a42c8b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.2
|
|
4
|
-
|
|
5
|
-
- Add License badge to README
|
|
6
|
-
- Add bug_tracker_uri to gemspec
|
|
7
|
-
|
|
8
3
|
All notable changes to this gem will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
7
|
|
|
13
8
|
## [Unreleased]
|
|
9
|
+
n## [0.3.1] - 2026-03-22
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Update rubocop configuration for Windows compatibility
|
|
13
|
+
|
|
14
|
+
## [0.3.0] - 2026-03-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Thread-safe `fetch` — compute-on-miss now holds the lock for the entire operation, preventing duplicate computation
|
|
18
|
+
- Eviction callbacks via `on_evict { |key, value| ... }` — fires on LRU eviction and TTL expiry
|
|
19
|
+
- Per-tag statistics via `stats(tag: :name)` — returns `{ hits:, misses:, evictions: }` for a specific tag
|
|
20
|
+
- Batch retrieval via `get_many(keys)` — fetch multiple keys in a single lock acquisition, returns a hash
|
|
21
|
+
- Snapshot and restore via `snapshot` / `restore(data)` — serialize and deserialize cache state for warm restarts
|
|
22
|
+
|
|
23
|
+
## [0.2.2] - 2026-03-16
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Add License badge to README
|
|
27
|
+
- Add bug_tracker_uri to gemspec
|
|
14
28
|
|
|
15
29
|
## [0.2.1] - 2026-03-13
|
|
16
30
|
|
data/README.md
CHANGED
|
@@ -18,12 +18,6 @@ Add to your Gemfile:
|
|
|
18
18
|
gem "philiprehberger-cache_kit"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Then run:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
bundle install
|
|
25
|
-
```
|
|
26
|
-
|
|
27
21
|
Or install directly:
|
|
28
22
|
|
|
29
23
|
```bash
|
|
@@ -42,11 +36,13 @@ cache.set("user:1", { name: "Alice" }, ttl: 300)
|
|
|
42
36
|
cache.get("user:1") # => { name: "Alice" }
|
|
43
37
|
```
|
|
44
38
|
|
|
45
|
-
### Fetch (
|
|
39
|
+
### Fetch (compute-on-miss)
|
|
40
|
+
|
|
41
|
+
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.
|
|
46
42
|
|
|
47
43
|
```ruby
|
|
48
44
|
user = cache.fetch("user:1", ttl: 300) do
|
|
49
|
-
User.find(1)
|
|
45
|
+
User.find(1) # only called on cache miss
|
|
50
46
|
end
|
|
51
47
|
```
|
|
52
48
|
|
|
@@ -69,6 +65,34 @@ cache.set("post:1", data3, tags: ["posts"])
|
|
|
69
65
|
cache.invalidate_tag("users") # removes user:1 and user:2, keeps post:1
|
|
70
66
|
```
|
|
71
67
|
|
|
68
|
+
### Eviction Callback
|
|
69
|
+
|
|
70
|
+
Register hooks that fire when entries are evicted by LRU pressure or TTL expiry.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
cache.on_evict do |key, value|
|
|
74
|
+
logger.info("Evicted #{key}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Fires on LRU eviction
|
|
78
|
+
cache.set("overflow", "data") # triggers callback if cache is full
|
|
79
|
+
|
|
80
|
+
# Fires on TTL expiry (during get or prune)
|
|
81
|
+
cache.prune
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Batch Get
|
|
85
|
+
|
|
86
|
+
Retrieve multiple keys in a single lock acquisition. Returns a hash mapping each key to its value (or nil if missing/expired).
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
cache.set("a", 1)
|
|
90
|
+
cache.set("b", 2)
|
|
91
|
+
|
|
92
|
+
cache.get_many(["a", "b", "missing"])
|
|
93
|
+
# => { "a" => 1, "b" => 2, "missing" => nil }
|
|
94
|
+
```
|
|
95
|
+
|
|
72
96
|
### Hash-like Access
|
|
73
97
|
|
|
74
98
|
```ruby
|
|
@@ -111,6 +135,36 @@ cache.stats
|
|
|
111
135
|
# => { size: 1, hits: 1, misses: 1, evictions: 0 }
|
|
112
136
|
```
|
|
113
137
|
|
|
138
|
+
### Stats by Tag
|
|
139
|
+
|
|
140
|
+
Track per-tag hit, miss, and eviction counters.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
cache.set("user:1", data, tags: ["users"])
|
|
144
|
+
cache.set("post:1", data, tags: ["posts"])
|
|
145
|
+
cache.get("user:1") # hit on "users" tag
|
|
146
|
+
|
|
147
|
+
cache.stats(tag: "users")
|
|
148
|
+
# => { hits: 1, misses: 0, evictions: 0 }
|
|
149
|
+
|
|
150
|
+
cache.stats(tag: "posts")
|
|
151
|
+
# => { hits: 0, misses: 0, evictions: 0 }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Snapshot and Restore
|
|
155
|
+
|
|
156
|
+
Serialize cache state for warm restarts. The snapshot captures all entries with their remaining TTL, tags, and LRU order.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# Save state before shutdown
|
|
160
|
+
data = cache.snapshot
|
|
161
|
+
File.write("cache.bin", Marshal.dump(data))
|
|
162
|
+
|
|
163
|
+
# Restore on startup
|
|
164
|
+
new_cache = Philiprehberger::CacheKit::Store.new(max_size: 500)
|
|
165
|
+
new_cache.restore(Marshal.load(File.read("cache.bin")))
|
|
166
|
+
```
|
|
167
|
+
|
|
114
168
|
## API
|
|
115
169
|
|
|
116
170
|
| Method | Description |
|
|
@@ -118,7 +172,7 @@ cache.stats
|
|
|
118
172
|
| `Store.new(max_size: 1000)` | Create a cache with max entries |
|
|
119
173
|
| `Store#get(key)` | Get a value (nil if missing/expired) |
|
|
120
174
|
| `Store#set(key, value, ttl:, tags:)` | Store a value |
|
|
121
|
-
| `Store#fetch(key, ttl:, tags:, &block)` | Get or compute a value |
|
|
175
|
+
| `Store#fetch(key, ttl:, tags:, &block)` | Get or compute a value (thread-safe) |
|
|
122
176
|
| `Store#delete(key)` | Delete a key |
|
|
123
177
|
| `Store#invalidate_tag(tag)` | Remove all entries with a tag |
|
|
124
178
|
| `Store#clear` | Remove all entries |
|
|
@@ -128,7 +182,12 @@ cache.stats
|
|
|
128
182
|
| `Store#[](key)` | Hash-like read (alias for `get`) |
|
|
129
183
|
| `Store#[]=(key, value)` | Hash-like write (alias for `set` without TTL/tags) |
|
|
130
184
|
| `Store#stats` | Returns `{ size:, hits:, misses:, evictions: }` |
|
|
185
|
+
| `Store#stats(tag: name)` | Returns `{ hits:, misses:, evictions: }` for a tag |
|
|
131
186
|
| `Store#prune` | Remove all expired entries, returns count removed |
|
|
187
|
+
| `Store#on_evict { \|key, value\| }` | Register eviction callback |
|
|
188
|
+
| `Store#get_many(keys)` | Batch get, returns `{ key => value }` hash |
|
|
189
|
+
| `Store#snapshot` | Serialize cache state to a hash |
|
|
190
|
+
| `Store#restore(data)` | Restore cache state from a snapshot |
|
|
132
191
|
|
|
133
192
|
## Development
|
|
134
193
|
|
|
@@ -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
|
|
35
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 { |_,
|
|
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]
|
|
111
|
-
def stats
|
|
112
|
-
@mutex.synchronize {
|
|
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
|
|
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
|
|
127
|
-
@
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
100
|
+
computed = block.call
|
|
101
|
+
store_entry(key, computed, ttl: ttl, tags: tags)
|
|
102
|
+
computed
|
|
143
103
|
end
|
|
144
104
|
|
|
145
|
-
def
|
|
146
|
-
@
|
|
147
|
-
nil
|
|
105
|
+
def delete_entry(key)
|
|
106
|
+
@data.key?(key).tap { remove_entry(key) }
|
|
148
107
|
end
|
|
149
108
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
116
|
+
def key_present?(key)
|
|
117
|
+
entry = @data[key]
|
|
118
|
+
entry ? !entry.expired? : false
|
|
156
119
|
end
|
|
157
120
|
|
|
158
|
-
def
|
|
159
|
-
@data.
|
|
160
|
-
|
|
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,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.
|
|
4
|
+
version: 0.3.1
|
|
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-
|
|
11
|
+
date: 2026-03-22 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:
|
|
@@ -32,6 +37,7 @@ metadata:
|
|
|
32
37
|
homepage_uri: https://github.com/philiprehberger/rb-cache-kit
|
|
33
38
|
source_code_uri: https://github.com/philiprehberger/rb-cache-kit
|
|
34
39
|
changelog_uri: https://github.com/philiprehberger/rb-cache-kit/blob/main/CHANGELOG.md
|
|
40
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-cache-kit/issues
|
|
35
41
|
rubygems_mfa_required: 'true'
|
|
36
42
|
post_install_message:
|
|
37
43
|
rdoc_options: []
|