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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +69 -3
- 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 +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12588884ddff5dd03ef08a73de323fbbdfe6c1a45c3c897d21345c3086ef2a52
|
|
4
|
+
data.tar.gz: 6379e4e269b302187fe80537570cf1df42fa189e63eb49af8fa8ca191f470fb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/philiprehberger/rb-cache-kit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-cache_kit)
|
|
5
|
+
[](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 (
|
|
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
|
|
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.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-
|
|
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:
|