cache_stache 0.1.1 → 0.2.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 +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +4 -4
- data/lib/cache_stache/cache_client.rb +10 -6
- data/lib/cache_stache/configuration.rb +44 -7
- data/lib/cache_stache/version.rb +1 -1
- data/lib/generators/cache_stache/templates/cache_stache.rb +9 -3
- data/spec/cache_stache_helper.rb +2 -2
- data/spec/unit/cache_client_spec.rb +22 -0
- data/spec/unit/configuration_spec.rb +61 -5
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b3e76904376faf81f690f9b8fd24d63c368295e9c133304b3c16bf0f6e48c6d
|
|
4
|
+
data.tar.gz: 3b23acace11232c359d8d9185946fcb595315ce89e4eacd5b9386de8290e9e71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ed34798119cada11fbbf64f51a61a291e725288dba4fd4bc82353a1feca9adfbf3f654c1070c7664b3e4397f0d2f48cb6ca504cb4963d2a62d7af639fd68c16
|
|
7
|
+
data.tar.gz: 49d081eec0e40ae8d2dddb6159aef9b5524d369770d7826981b0e6a2da2c3cae90c50e6ae34ce7b263f76487ea6aa59a51d55a788c2884caa4f7a35108a685ef
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.1]
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Fixed Redis calls when `bucket_seconds` or `retention_seconds` are configured with `ActiveSupport::Duration` values
|
|
13
|
+
|
|
14
|
+
## [0.2.0]
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Breaking:** Replaced `redis_url` config option with `redis`, which accepts a Proc, String URL, or Redis-compatible object
|
|
19
|
+
|
|
20
|
+
## [0.1.1]
|
|
21
|
+
|
|
22
|
+
- Fixed Rails dependency to be >=, not ~>
|
|
23
|
+
|
|
24
|
+
## [0.1.0]
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Initial release
|
|
29
|
+
- Cache hit/miss rate tracking via Rails instrumentation
|
|
30
|
+
- Redis-backed statistics storage
|
|
31
|
+
- Dashboard UI for viewing cache metrics
|
|
32
|
+
- Keyspace breakdown view
|
|
33
|
+
- Configurable time windows (minute, hour, day)
|
|
34
|
+
- Rake tasks for cache statistics
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nate Berkopec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -63,9 +63,9 @@ All settings go in `config/initializers/cache_stache.rb`:
|
|
|
63
63
|
|
|
64
64
|
```ruby
|
|
65
65
|
CacheStache.configure do |config|
|
|
66
|
-
# Redis connection for storing cache metrics
|
|
67
|
-
#
|
|
68
|
-
config.
|
|
66
|
+
# Redis connection for storing cache metrics.
|
|
67
|
+
# Can be a String (URL), Proc, or Redis-compatible object.
|
|
68
|
+
config.redis = ENV.fetch("CACHE_STACHE_REDIS_URL", ENV["REDIS_URL"])
|
|
69
69
|
|
|
70
70
|
# Time bucket size
|
|
71
71
|
config.bucket_seconds = 5.minutes
|
|
@@ -99,7 +99,7 @@ end
|
|
|
99
99
|
|
|
100
100
|
| Setting | Default | What it does |
|
|
101
101
|
|---------|---------|--------------|
|
|
102
|
-
| `
|
|
102
|
+
| `redis` | `ENV["CACHE_STACHE_REDIS_URL"]` or `ENV["REDIS_URL"]` | Redis connection (String URL, Proc, or Redis object) |
|
|
103
103
|
| `redis_pool_size` | 5 | Size of the Redis connection pool |
|
|
104
104
|
| `bucket_seconds` | 5 minutes | Size of each time bucket |
|
|
105
105
|
| `retention_seconds` | 7 days | How long to keep data |
|
|
@@ -26,7 +26,7 @@ module CacheStache
|
|
|
26
26
|
def initialize(config = CacheStache.configuration)
|
|
27
27
|
@config = config
|
|
28
28
|
@pool = ConnectionPool.new(size: @config.redis_pool_size) do
|
|
29
|
-
|
|
29
|
+
@config.build_redis
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -39,7 +39,7 @@ module CacheStache
|
|
|
39
39
|
redis.eval(
|
|
40
40
|
INCR_AND_EXPIRE_SCRIPT,
|
|
41
41
|
keys: [key],
|
|
42
|
-
argv: [
|
|
42
|
+
argv: [retention_seconds, increments.to_json]
|
|
43
43
|
)
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -79,16 +79,16 @@ module CacheStache
|
|
|
79
79
|
def store_config_metadata
|
|
80
80
|
key = "cache_stache:v1:#{@config.rails_env}:config"
|
|
81
81
|
metadata = {
|
|
82
|
-
bucket_seconds: @config.bucket_seconds,
|
|
83
|
-
retention_seconds:
|
|
82
|
+
bucket_seconds: @config.bucket_seconds.to_i,
|
|
83
|
+
retention_seconds: retention_seconds,
|
|
84
84
|
updated_at: Time.current.to_i
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
without_instrumentation do
|
|
88
88
|
@pool.with do |redis|
|
|
89
89
|
# Use SETEX for atomic set-with-expiry (single command)
|
|
90
|
-
Rails.logger.debug { "CacheStache: Redis SETEX #{key} #{
|
|
91
|
-
redis.setex(key,
|
|
90
|
+
Rails.logger.debug { "CacheStache: Redis SETEX #{key} #{retention_seconds}" }
|
|
91
|
+
redis.setex(key, retention_seconds, metadata.to_json)
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
rescue => e
|
|
@@ -164,6 +164,10 @@ module CacheStache
|
|
|
164
164
|
end
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
def retention_seconds
|
|
168
|
+
@config.retention_seconds.to_i
|
|
169
|
+
end
|
|
170
|
+
|
|
167
171
|
def bucket_key(timestamp)
|
|
168
172
|
"cache_stache:v1:#{@config.rails_env}:#{timestamp}"
|
|
169
173
|
end
|
|
@@ -5,23 +5,53 @@ require "active_support/core_ext/numeric/time"
|
|
|
5
5
|
|
|
6
6
|
module CacheStache
|
|
7
7
|
class Configuration
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
DEFAULT_REDIS_OPTIONS = {reconnect_attempts: 0}.freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :sample_rate, :enabled, :redis, :redis_pool_size,
|
|
11
|
+
:use_rack_after_reply, :max_buckets
|
|
12
|
+
attr_reader :bucket_seconds, :retention_seconds, :keyspaces
|
|
11
13
|
|
|
12
14
|
def initialize
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
self.bucket_seconds = 5.minutes
|
|
16
|
+
self.retention_seconds = 7.days
|
|
15
17
|
@sample_rate = 1.0
|
|
16
18
|
@enabled = rails_env != "test"
|
|
17
19
|
@use_rack_after_reply = false
|
|
18
|
-
@
|
|
20
|
+
@redis = ENV.fetch("CACHE_STACHE_REDIS_URL") { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
19
21
|
@redis_pool_size = 5
|
|
20
22
|
@max_buckets = 288
|
|
21
23
|
@keyspaces = []
|
|
22
24
|
@keyspace_cache = {}
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# Factory method to create a new Redis instance.
|
|
28
|
+
#
|
|
29
|
+
# Handles three options:
|
|
30
|
+
#
|
|
31
|
+
# Option Class Result
|
|
32
|
+
# :redis Proc -> redis.call
|
|
33
|
+
# :redis String -> Redis.new(url: redis)
|
|
34
|
+
# :redis Object -> redis (assumed to be a Redis-compatible client)
|
|
35
|
+
#
|
|
36
|
+
def bucket_seconds=(value)
|
|
37
|
+
@bucket_seconds = value.to_i
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def retention_seconds=(value)
|
|
41
|
+
@retention_seconds = value.to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_redis
|
|
45
|
+
case redis
|
|
46
|
+
when Proc
|
|
47
|
+
redis.call
|
|
48
|
+
when String
|
|
49
|
+
::Redis.new(DEFAULT_REDIS_OPTIONS.merge(url: redis))
|
|
50
|
+
else
|
|
51
|
+
redis
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
25
55
|
def keyspace(name, &block)
|
|
26
56
|
ks = Keyspace.new(name)
|
|
27
57
|
builder = KeyspaceBuilder.new(ks)
|
|
@@ -43,8 +73,9 @@ module CacheStache
|
|
|
43
73
|
def validate!
|
|
44
74
|
raise Error, "bucket_seconds must be positive" unless bucket_seconds.to_i.positive?
|
|
45
75
|
raise Error, "retention_seconds must be positive" unless retention_seconds.to_i.positive?
|
|
76
|
+
raise Error, "redis must be configured" if redis.nil?
|
|
77
|
+
raise Error, "redis must be a Proc, String (URL), or Redis-compatible object" unless valid_redis_option?
|
|
46
78
|
raise Error, "redis_pool_size must be positive" unless redis_pool_size.to_i.positive?
|
|
47
|
-
raise Error, "redis_url must be configured" if redis_url.to_s.strip.empty?
|
|
48
79
|
raise Error, "sample_rate must be between 0 and 1" unless sample_rate&.between?(0, 1)
|
|
49
80
|
raise Error, "max_buckets must be positive" unless max_buckets.to_i.positive?
|
|
50
81
|
|
|
@@ -64,6 +95,12 @@ module CacheStache
|
|
|
64
95
|
|
|
65
96
|
private
|
|
66
97
|
|
|
98
|
+
def valid_redis_option?
|
|
99
|
+
return true if redis.is_a?(Proc)
|
|
100
|
+
return redis.to_s.strip.length > 0 if redis.is_a?(String)
|
|
101
|
+
true # Assume other objects are Redis-compatible clients
|
|
102
|
+
end
|
|
103
|
+
|
|
67
104
|
def key_digest(key)
|
|
68
105
|
# Use last 4 chars of a simple hash as cache key
|
|
69
106
|
Digest::MD5.hexdigest(key.to_s)[-4..]
|
data/lib/cache_stache/version.rb
CHANGED
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
# This file configures the CacheStache cache hit rate monitoring system.
|
|
5
5
|
|
|
6
6
|
CacheStache.configure do |config|
|
|
7
|
-
# Redis connection for storing cache metrics
|
|
8
|
-
#
|
|
9
|
-
|
|
7
|
+
# Redis connection for storing cache metrics.
|
|
8
|
+
# Can be a String (URL), Proc, or Redis-compatible object.
|
|
9
|
+
#
|
|
10
|
+
# Examples:
|
|
11
|
+
# config.redis = "redis://localhost:6379/0"
|
|
12
|
+
# config.redis = -> { Redis.new(url: ENV["REDIS_URL"]) }
|
|
13
|
+
# config.redis = ConnectionPool.new { Redis.new }
|
|
14
|
+
#
|
|
15
|
+
config.redis = ENV.fetch("CACHE_STACHE_REDIS_URL", ENV["REDIS_URL"])
|
|
10
16
|
|
|
11
17
|
# Size of time buckets for aggregation (default: 5 minutes)
|
|
12
18
|
config.bucket_seconds = 5.minutes
|
data/spec/cache_stache_helper.rb
CHANGED
|
@@ -50,7 +50,7 @@ module CacheStacheTestHelpers
|
|
|
50
50
|
# Build a test configuration with common defaults
|
|
51
51
|
def build_test_config(keyspaces: {}, **options)
|
|
52
52
|
CacheStache::Configuration.new.tap do |c|
|
|
53
|
-
c.
|
|
53
|
+
c.redis = CACHE_STACHE_TEST_REDIS_URL
|
|
54
54
|
c.bucket_seconds = options.fetch(:bucket_seconds, 300)
|
|
55
55
|
c.retention_seconds = options.fetch(:retention_seconds, 3600)
|
|
56
56
|
c.sample_rate = options.fetch(:sample_rate, 1.0)
|
|
@@ -87,7 +87,7 @@ RSpec.configure do |config|
|
|
|
87
87
|
config.before do
|
|
88
88
|
# Configure CacheStache to use test Redis
|
|
89
89
|
CacheStache.configure do |c|
|
|
90
|
-
c.
|
|
90
|
+
c.redis = CACHE_STACHE_TEST_REDIS_URL
|
|
91
91
|
c.redis_pool_size = 1
|
|
92
92
|
c.enabled = true
|
|
93
93
|
end
|
|
@@ -75,6 +75,17 @@ RSpec.describe CacheStache::CacheClient do
|
|
|
75
75
|
expect(ttl).to be <= config.retention_seconds
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
it "increments stats when retention is configured with ActiveSupport::Duration" do
|
|
79
|
+
duration_config = build_test_config(retention_seconds: 7.days)
|
|
80
|
+
duration_client = described_class.new(duration_config)
|
|
81
|
+
|
|
82
|
+
duration_client.increment_stats(bucket_ts, increments)
|
|
83
|
+
|
|
84
|
+
buckets = duration_client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
|
|
85
|
+
expect(buckets.size).to eq(1)
|
|
86
|
+
expect(buckets.first[:stats]["overall:hits"]).to eq(1.0)
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
it "handles errors gracefully" do
|
|
79
90
|
# Create client, then make the pool raise errors
|
|
80
91
|
test_client = described_class.new(config)
|
|
@@ -168,6 +179,17 @@ RSpec.describe CacheStache::CacheClient do
|
|
|
168
179
|
expect(metadata["updated_at"]).to be_a(Integer)
|
|
169
180
|
end
|
|
170
181
|
|
|
182
|
+
it "stores configuration metadata when durations use ActiveSupport::Duration" do
|
|
183
|
+
duration_config = build_test_config(bucket_seconds: 5.minutes, retention_seconds: 7.days)
|
|
184
|
+
duration_client = described_class.new(duration_config)
|
|
185
|
+
|
|
186
|
+
duration_client.store_config_metadata
|
|
187
|
+
metadata = duration_client.fetch_config_metadata
|
|
188
|
+
|
|
189
|
+
expect(metadata["bucket_seconds"]).to eq(5.minutes.to_i)
|
|
190
|
+
expect(metadata["retention_seconds"]).to eq(7.days.to_i)
|
|
191
|
+
end
|
|
192
|
+
|
|
171
193
|
it "handles errors gracefully" do
|
|
172
194
|
# Create client, then make the pool raise errors
|
|
173
195
|
test_client = described_class.new(config)
|
|
@@ -19,11 +19,52 @@ RSpec.describe CacheStache::Configuration do
|
|
|
19
19
|
it { expect(config.sample_rate).to eq(1.0) }
|
|
20
20
|
it { expect(config.enabled).to be(true) }
|
|
21
21
|
it { expect(config.use_rack_after_reply).to be(false) }
|
|
22
|
-
it { expect(config.
|
|
22
|
+
it { expect(config.redis).to eq(default_redis_url) }
|
|
23
23
|
it { expect(config.redis_pool_size).to eq(5) }
|
|
24
24
|
it { expect(config.keyspaces).to eq([]) }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
describe "duration settings" do
|
|
28
|
+
it "coerces bucket_seconds to integer seconds" do
|
|
29
|
+
config.bucket_seconds = 5.minutes
|
|
30
|
+
|
|
31
|
+
expect(config.bucket_seconds).to eq(300)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "coerces retention_seconds to integer seconds" do
|
|
35
|
+
config.retention_seconds = 7.days
|
|
36
|
+
|
|
37
|
+
expect(config.retention_seconds).to eq(604_800)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe "#build_redis" do
|
|
42
|
+
it "calls the proc when redis is a Proc" do
|
|
43
|
+
redis_instance = instance_double(Redis)
|
|
44
|
+
config.redis = -> { redis_instance }
|
|
45
|
+
|
|
46
|
+
expect(config.build_redis).to eq(redis_instance)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "creates a Redis instance when redis is a String URL" do
|
|
50
|
+
config.redis = "redis://localhost:6379/1"
|
|
51
|
+
|
|
52
|
+
expect(Redis).to receive(:new).with(
|
|
53
|
+
hash_including(url: "redis://localhost:6379/1")
|
|
54
|
+
).and_call_original
|
|
55
|
+
|
|
56
|
+
result = config.build_redis
|
|
57
|
+
expect(result).to be_a(Redis)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "returns the object directly when redis is an Object" do
|
|
61
|
+
redis_instance = instance_double(Redis)
|
|
62
|
+
config.redis = redis_instance
|
|
63
|
+
|
|
64
|
+
expect(config.build_redis).to eq(redis_instance)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
27
68
|
describe "#keyspace" do
|
|
28
69
|
it "adds a keyspace with the given name" do
|
|
29
70
|
config.keyspace(:views) do
|
|
@@ -135,12 +176,27 @@ RSpec.describe CacheStache::Configuration do
|
|
|
135
176
|
|
|
136
177
|
describe "#validate!" do
|
|
137
178
|
before do
|
|
138
|
-
config.
|
|
179
|
+
config.redis = "redis://localhost:6379/0"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "requires redis" do
|
|
183
|
+
config.redis = nil
|
|
184
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /redis must be configured/)
|
|
139
185
|
end
|
|
140
186
|
|
|
141
|
-
it "
|
|
142
|
-
config.
|
|
143
|
-
expect { config.validate! }.to raise_error(CacheStache::Error, /
|
|
187
|
+
it "rejects empty string for redis" do
|
|
188
|
+
config.redis = " "
|
|
189
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /redis must be a Proc, String/)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "accepts a Proc for redis" do
|
|
193
|
+
config.redis = -> { Redis.new }
|
|
194
|
+
expect { config.validate! }.not_to raise_error
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "accepts an Object for redis" do
|
|
198
|
+
config.redis = instance_double(Redis)
|
|
199
|
+
expect { config.validate! }.not_to raise_error
|
|
144
200
|
end
|
|
145
201
|
|
|
146
202
|
it "requires redis_pool_size to be positive" do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cache_stache
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CacheStache contributors
|
|
@@ -155,6 +155,8 @@ executables: []
|
|
|
155
155
|
extensions: []
|
|
156
156
|
extra_rdoc_files: []
|
|
157
157
|
files:
|
|
158
|
+
- CHANGELOG.md
|
|
159
|
+
- LICENSE.txt
|
|
158
160
|
- README.md
|
|
159
161
|
- app/assets/stylesheets/cache_stache/application.css
|
|
160
162
|
- app/assets/stylesheets/cache_stache/pico.css
|