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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d78eea9e2e19c6f95024644eaea2dc942993225c42ca636473e19e46fd6f449
4
- data.tar.gz: 6156b5fe4adc1756b1ebf1fe92e413ab16bba3589557e26d17efe3ef2fa18dbf
3
+ metadata.gz: 3b3e76904376faf81f690f9b8fd24d63c368295e9c133304b3c16bf0f6e48c6d
4
+ data.tar.gz: 3b23acace11232c359d8d9185946fcb595315ce89e4eacd5b9386de8290e9e71
5
5
  SHA512:
6
- metadata.gz: b976afa17c53451ea8b6aa57e6d15d88f097f9fd72bc8bd420b91911f06af686f7f7693eee76f9981fd1644b3abd3694c942c6a1e28e764ec97b4148a8062f47
7
- data.tar.gz: d31764e21a67a0b2006a59593f1b4757f95a731817d3c8cf0d6af802bef0e7afcbc9e5d0b30f1069d77b6606b60a1f62b6f24315ee9fafb8b77e20a6c71a2ff1
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
- # Falls back to ENV["REDIS_URL"] if not set
68
- config.redis_url = ENV.fetch("CACHE_STACHE_REDIS_URL", ENV["REDIS_URL"])
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
- | `redis_url` | `ENV["CACHE_STACHE_REDIS_URL"]` or `ENV["REDIS_URL"]` | Redis connection URL |
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
- Redis.new(url: @config.redis_url)
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: [@config.retention_seconds, increments.to_json]
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: @config.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} #{@config.retention_seconds}" }
91
- redis.setex(key, @config.retention_seconds, metadata.to_json)
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
- attr_accessor :bucket_seconds, :retention_seconds, :sample_rate, :enabled,
9
- :redis_url, :redis_pool_size, :use_rack_after_reply, :max_buckets
10
- attr_reader :keyspaces
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
- @bucket_seconds = 5.minutes.to_i
14
- @retention_seconds = 7.days.to_i
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
- @redis_url = ENV.fetch("CACHE_STACHE_REDIS_URL") { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
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..]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CacheStache
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -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
- # Falls back to ENV["REDIS_URL"] if not set
9
- config.redis_url = ENV.fetch("CACHE_STACHE_REDIS_URL", ENV["REDIS_URL"])
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
@@ -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.redis_url = CACHE_STACHE_TEST_REDIS_URL
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.redis_url = CACHE_STACHE_TEST_REDIS_URL
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.redis_url).to eq(default_redis_url) }
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.redis_url = "redis://localhost:6379/0"
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 "requires redis_url" do
142
- config.redis_url = nil
143
- expect { config.validate! }.to raise_error(CacheStache::Error, /redis_url must be configured/)
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.1.1
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