cache_stache 0.1.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 +7 -0
- data/README.md +231 -0
- data/app/assets/stylesheets/cache_stache/application.css +5 -0
- data/app/assets/stylesheets/cache_stache/pico.css +4 -0
- data/app/controllers/cache_stache/application_controller.rb +11 -0
- data/app/controllers/cache_stache/dashboard_controller.rb +32 -0
- data/app/helpers/cache_stache/application_helper.rb +37 -0
- data/app/views/cache_stache/dashboard/index.html.erb +154 -0
- data/app/views/cache_stache/dashboard/keyspace.html.erb +83 -0
- data/app/views/layouts/cache_stache/application.html.erb +14 -0
- data/config/routes.rb +6 -0
- data/lib/cache_stache/cache_client.rb +202 -0
- data/lib/cache_stache/configuration.rb +87 -0
- data/lib/cache_stache/engine.rb +17 -0
- data/lib/cache_stache/instrumentation.rb +142 -0
- data/lib/cache_stache/keyspace.rb +28 -0
- data/lib/cache_stache/rack_after_reply_middleware.rb +22 -0
- data/lib/cache_stache/railtie.rb +30 -0
- data/lib/cache_stache/stats_query.rb +89 -0
- data/lib/cache_stache/version.rb +5 -0
- data/lib/cache_stache/web.rb +69 -0
- data/lib/cache_stache/window_options.rb +34 -0
- data/lib/cache_stache.rb +37 -0
- data/lib/generators/cache_stache/install_generator.rb +21 -0
- data/lib/generators/cache_stache/templates/README +35 -0
- data/lib/generators/cache_stache/templates/cache_stache.rb +43 -0
- data/spec/cache_stache_helper.rb +148 -0
- data/spec/dummy_app/Rakefile +5 -0
- data/spec/dummy_app/app/assets/config/manifest.js +1 -0
- data/spec/dummy_app/config/application.rb +31 -0
- data/spec/dummy_app/config/boot.rb +3 -0
- data/spec/dummy_app/config/environment.rb +5 -0
- data/spec/dummy_app/config/routes.rb +7 -0
- data/spec/integration/dashboard_controller_spec.rb +94 -0
- data/spec/integration/full_cache_flow_spec.rb +202 -0
- data/spec/integration/instrumentation_spec.rb +259 -0
- data/spec/integration/rack_after_reply_spec.rb +47 -0
- data/spec/integration/rake_tasks_spec.rb +17 -0
- data/spec/spec_helper.rb +64 -0
- data/spec/unit/cache_client_spec.rb +278 -0
- data/spec/unit/configuration_spec.rb +209 -0
- data/spec/unit/keyspace_spec.rb +93 -0
- data/spec/unit/stats_query_spec.rb +367 -0
- data/tasks/cache_stache.rake +74 -0
- metadata +226 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_stache_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Full Cache Flow" do
|
|
6
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
7
|
+
|
|
8
|
+
include_context "with instrumentation and search"
|
|
9
|
+
|
|
10
|
+
after do
|
|
11
|
+
travel_back if respond_to?(:travel_back)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe "complete workflow" do
|
|
15
|
+
it "tracks cache operations end-to-end" do
|
|
16
|
+
# Generate cache misses (first fetch)
|
|
17
|
+
10.times { |i| Rails.cache.fetch("views/product_#{i}") { "Product #{i}" } }
|
|
18
|
+
|
|
19
|
+
# Generate cache hits (repeat fetch)
|
|
20
|
+
5.times { Rails.cache.fetch("views/product_1") { "Product 1" } }
|
|
21
|
+
|
|
22
|
+
# Generate model cache operations
|
|
23
|
+
3.times { |i| Rails.cache.fetch("community/#{i}") { "Community #{i}" } }
|
|
24
|
+
|
|
25
|
+
# Generate operations matching multiple keyspaces
|
|
26
|
+
Rails.cache.fetch("views/community_search") { "Search results" }
|
|
27
|
+
|
|
28
|
+
sleep 0.2 # Allow instrumentation to process
|
|
29
|
+
|
|
30
|
+
# Query statistics
|
|
31
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
32
|
+
results = query.execute
|
|
33
|
+
|
|
34
|
+
# Verify overall stats
|
|
35
|
+
expect(results[:overall][:total_operations]).to be >= 18 # 10 + 5 + 3
|
|
36
|
+
expect(results[:overall][:hits]).to be >= 5
|
|
37
|
+
expect(results[:overall][:misses]).to be >= 13
|
|
38
|
+
|
|
39
|
+
# Verify view keyspace stats
|
|
40
|
+
expect(results[:keyspaces][:views][:total_operations]).to be >= 16 # 10 + 5 + 1
|
|
41
|
+
expect(results[:keyspaces][:views][:hits]).to be >= 5
|
|
42
|
+
|
|
43
|
+
# Verify model keyspace stats
|
|
44
|
+
expect(results[:keyspaces][:models][:total_operations]).to be >= 4 # 3 + 1
|
|
45
|
+
|
|
46
|
+
# Verify search keyspace stats
|
|
47
|
+
expect(results[:keyspaces][:search][:total_operations]).to be >= 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "calculates accurate hit rates" do
|
|
51
|
+
# Generate known pattern: 10 misses, 5 hits
|
|
52
|
+
10.times { |i| Rails.cache.fetch("views/item_#{i}") { "Item #{i}" } }
|
|
53
|
+
5.times { Rails.cache.fetch("views/item_1") { "Item 1" } }
|
|
54
|
+
|
|
55
|
+
sleep 0.2
|
|
56
|
+
|
|
57
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
58
|
+
results = query.execute
|
|
59
|
+
|
|
60
|
+
# Total operations = 15, hits = 5, misses = 10
|
|
61
|
+
# Hit rate should be ~33%
|
|
62
|
+
expect(results[:overall][:total_operations]).to eq(15)
|
|
63
|
+
expect(results[:overall][:hits]).to eq(5)
|
|
64
|
+
expect(results[:overall][:misses]).to eq(10)
|
|
65
|
+
expect(results[:overall][:hit_rate_percent]).to be_within(0.1).of(33.33)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "handles mixed cache operations" do
|
|
69
|
+
# Mix of reads, writes, and fetches
|
|
70
|
+
Rails.cache.write("test_1", "value")
|
|
71
|
+
Rails.cache.read("test_1") # hit
|
|
72
|
+
Rails.cache.read("nonexistent") # miss
|
|
73
|
+
Rails.cache.fetch("test_2") { "value" } # miss (write + read)
|
|
74
|
+
Rails.cache.fetch("test_2") { "value" } # hit
|
|
75
|
+
|
|
76
|
+
sleep 0.2
|
|
77
|
+
|
|
78
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
79
|
+
results = query.execute
|
|
80
|
+
|
|
81
|
+
# Only reads are tracked
|
|
82
|
+
expect(results[:overall][:total_operations]).to be >= 4
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "handles keyspaces with overlapping patterns" do
|
|
86
|
+
# Key matches both views and search keyspaces
|
|
87
|
+
Rails.cache.fetch("views/search_page") { "content" }
|
|
88
|
+
|
|
89
|
+
sleep 0.2
|
|
90
|
+
|
|
91
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
92
|
+
results = query.execute
|
|
93
|
+
|
|
94
|
+
# Both keyspaces should record the operation
|
|
95
|
+
expect(results[:keyspaces][:views][:total_operations]).to eq(1)
|
|
96
|
+
expect(results[:keyspaces][:search][:total_operations]).to eq(1)
|
|
97
|
+
|
|
98
|
+
# Overall should still count it once
|
|
99
|
+
expect(results[:overall][:total_operations]).to eq(1)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe "edge cases" do
|
|
104
|
+
it "handles cache keys with special characters" do
|
|
105
|
+
special_keys = [
|
|
106
|
+
"views/product:123",
|
|
107
|
+
"views/category/sub-category",
|
|
108
|
+
"views/item.json",
|
|
109
|
+
"views/search?query=test"
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
special_keys.each { |key| Rails.cache.fetch(key) { "value" } }
|
|
113
|
+
sleep 0.2
|
|
114
|
+
|
|
115
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
116
|
+
results = query.execute
|
|
117
|
+
|
|
118
|
+
expect(results[:keyspaces][:views][:total_operations]).to eq(special_keys.size)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "handles very high operation volumes" do
|
|
122
|
+
100.times { |i| Rails.cache.fetch("views/item_#{i}") { "value" } }
|
|
123
|
+
sleep 0.3
|
|
124
|
+
|
|
125
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
126
|
+
results = query.execute
|
|
127
|
+
|
|
128
|
+
expect(results[:overall][:total_operations]).to eq(100)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "handles cache operations with nil values" do
|
|
132
|
+
Rails.cache.write("nil_value", nil)
|
|
133
|
+
Rails.cache.read("nil_value") # This is a hit
|
|
134
|
+
|
|
135
|
+
sleep 0.2
|
|
136
|
+
|
|
137
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
138
|
+
results = query.execute
|
|
139
|
+
|
|
140
|
+
expect(results[:overall][:hits]).to be >= 1
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
describe "realistic usage scenarios" do
|
|
145
|
+
it "simulates a typical request pattern" do
|
|
146
|
+
# Simulate multiple requests hitting various cache keys
|
|
147
|
+
|
|
148
|
+
# Request 1: Homepage (all hits after first)
|
|
149
|
+
Rails.cache.fetch("views/homepage") { "content" }
|
|
150
|
+
3.times { Rails.cache.fetch("views/homepage") { "content" } }
|
|
151
|
+
|
|
152
|
+
# Request 2: Product listings (some hits, some misses)
|
|
153
|
+
Rails.cache.fetch("views/products/list") { "list" }
|
|
154
|
+
Rails.cache.fetch("views/products/list") { "list" }
|
|
155
|
+
|
|
156
|
+
# Request 3: Community data (misses)
|
|
157
|
+
5.times { |i| Rails.cache.fetch("community/#{i}/stats") { "stats" } }
|
|
158
|
+
|
|
159
|
+
# Request 4: Search results (misses)
|
|
160
|
+
Rails.cache.fetch("search/results?q=test") { "results" }
|
|
161
|
+
|
|
162
|
+
sleep 0.2
|
|
163
|
+
|
|
164
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
165
|
+
results = query.execute
|
|
166
|
+
|
|
167
|
+
# Verify realistic metrics
|
|
168
|
+
expect(results[:overall][:total_operations]).to be >= 12
|
|
169
|
+
expect(results[:overall][:hit_rate_percent]).to be > 0
|
|
170
|
+
expect(results[:overall][:hit_rate_percent]).to be < 100
|
|
171
|
+
|
|
172
|
+
# Verify keyspace breakdown
|
|
173
|
+
expect(results[:keyspaces][:views][:total_operations]).to be >= 6
|
|
174
|
+
expect(results[:keyspaces][:models][:total_operations]).to be >= 5
|
|
175
|
+
expect(results[:keyspaces][:search][:total_operations]).to be >= 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "simulates gradual cache warming" do
|
|
179
|
+
# First pass: all misses (cold cache)
|
|
180
|
+
10.times { |i| Rails.cache.fetch("warming_item_#{i}") { "value" } }
|
|
181
|
+
sleep 0.2
|
|
182
|
+
|
|
183
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
184
|
+
results_after_cold = query.execute
|
|
185
|
+
cold_misses = results_after_cold[:overall][:misses]
|
|
186
|
+
expect(cold_misses).to eq(10)
|
|
187
|
+
|
|
188
|
+
# Second pass: all hits (warm cache)
|
|
189
|
+
10.times { |i| Rails.cache.fetch("warming_item_#{i}") { "value" } }
|
|
190
|
+
sleep 0.2
|
|
191
|
+
|
|
192
|
+
query = CacheStache::StatsQuery.new(window: 5.minutes)
|
|
193
|
+
results_after_warm = query.execute
|
|
194
|
+
|
|
195
|
+
# Should now have 10 misses + 10 hits = 20 total, 50% hit rate
|
|
196
|
+
expect(results_after_warm[:overall][:total_operations]).to eq(20)
|
|
197
|
+
expect(results_after_warm[:overall][:hits]).to eq(10)
|
|
198
|
+
expect(results_after_warm[:overall][:misses]).to eq(10)
|
|
199
|
+
expect(results_after_warm[:overall][:hit_rate_percent]).to eq(50.0)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_stache_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CacheStache::Instrumentation do
|
|
6
|
+
let(:config) do
|
|
7
|
+
build_test_config(
|
|
8
|
+
keyspaces: {
|
|
9
|
+
views: {label: "View Fragments", match: /^views\//},
|
|
10
|
+
models: {label: "Model Cache", match: /community/}
|
|
11
|
+
}
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
allow(CacheStache).to receive(:configuration).and_return(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe ".install!" do
|
|
20
|
+
it "installs instrumentation when enabled" do
|
|
21
|
+
expect(Rails.logger).to receive(:info).with(/Instrumentation installed/)
|
|
22
|
+
described_class.install!
|
|
23
|
+
|
|
24
|
+
expect(described_class.instance_variable_get(:@installed)).to be(true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "does not install when disabled" do
|
|
28
|
+
config.enabled = false
|
|
29
|
+
described_class.install!
|
|
30
|
+
|
|
31
|
+
expect(described_class.instance_variable_get(:@installed)).to be_falsey
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "does not install twice" do
|
|
35
|
+
described_class.install!
|
|
36
|
+
expect(Rails.logger).not_to receive(:info)
|
|
37
|
+
described_class.install!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "subscribes to cache_read.active_support" do
|
|
41
|
+
described_class.install!
|
|
42
|
+
|
|
43
|
+
listeners = ActiveSupport::Notifications.notifier.listeners_for("cache_read.active_support")
|
|
44
|
+
expect(listeners).not_to be_empty
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "creates a cache client" do
|
|
48
|
+
described_class.install!
|
|
49
|
+
cache_client = described_class.instance_variable_get(:@cache_client)
|
|
50
|
+
|
|
51
|
+
expect(cache_client).to be_a(CacheStache::CacheClient)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "stores config metadata" do
|
|
55
|
+
described_class.install!
|
|
56
|
+
|
|
57
|
+
raw_metadata = cache_stache_redis.get("cache_stache:v1:test:config")
|
|
58
|
+
metadata = raw_metadata ? JSON.parse(raw_metadata) : nil
|
|
59
|
+
expect(metadata).not_to be_nil
|
|
60
|
+
expect(metadata["bucket_seconds"]).to eq(300)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "captures the monitored store class name" do
|
|
64
|
+
described_class.install!
|
|
65
|
+
|
|
66
|
+
expect(described_class.monitored_store_class).to eq(Rails.cache.class.name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe ".call" do
|
|
71
|
+
before do
|
|
72
|
+
described_class.install!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "records cache hits" do
|
|
76
|
+
Rails.cache.write("test_key", "value")
|
|
77
|
+
Rails.cache.read("test_key")
|
|
78
|
+
|
|
79
|
+
sleep 0.1
|
|
80
|
+
|
|
81
|
+
expect(current_bucket_stats["overall:hits"].to_f).to be >= 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "records cache misses" do
|
|
85
|
+
Rails.cache.read("nonexistent_key")
|
|
86
|
+
|
|
87
|
+
sleep 0.1
|
|
88
|
+
|
|
89
|
+
expect(current_bucket_stats["overall:misses"].to_f).to be >= 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "records keyspace hits when key matches" do
|
|
93
|
+
Rails.cache.write("views/product_123", "value")
|
|
94
|
+
Rails.cache.read("views/product_123")
|
|
95
|
+
|
|
96
|
+
sleep 0.1
|
|
97
|
+
|
|
98
|
+
expect(current_bucket_stats["views:hits"].to_f).to be >= 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "records keyspace misses when key matches" do
|
|
102
|
+
Rails.cache.read("views/nonexistent")
|
|
103
|
+
|
|
104
|
+
sleep 0.1
|
|
105
|
+
|
|
106
|
+
expect(current_bucket_stats["views:misses"].to_f).to be >= 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "records multiple keyspaces when key matches multiple patterns" do
|
|
110
|
+
Rails.cache.write("views/community_123", "value")
|
|
111
|
+
Rails.cache.read("views/community_123")
|
|
112
|
+
|
|
113
|
+
sleep 0.1
|
|
114
|
+
|
|
115
|
+
stats = current_bucket_stats
|
|
116
|
+
expect(stats["views:hits"].to_f).to be >= 1
|
|
117
|
+
expect(stats["models:hits"].to_f).to be >= 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "ignores cache_stache keys to prevent recursion" do
|
|
121
|
+
Rails.cache.read("cache_stache:v1:test:12345")
|
|
122
|
+
|
|
123
|
+
sleep 0.1
|
|
124
|
+
|
|
125
|
+
stats = Rails.cache.read(current_bucket_key)
|
|
126
|
+
|
|
127
|
+
# Should not record the cache_stache key read
|
|
128
|
+
expect(stats).to be_nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "handles payloads with :key field" do
|
|
132
|
+
payload = {key: "test_key", hit: true, store: Rails.cache.class.name}
|
|
133
|
+
described_class.call(nil, nil, nil, nil, payload)
|
|
134
|
+
|
|
135
|
+
sleep 0.1
|
|
136
|
+
|
|
137
|
+
expect(current_bucket_stats["overall:hits"].to_f).to be >= 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "handles payloads with :name field" do
|
|
141
|
+
payload = {name: "test_key", hit: false, store: Rails.cache.class.name}
|
|
142
|
+
described_class.call(nil, nil, nil, nil, payload)
|
|
143
|
+
|
|
144
|
+
sleep 0.1
|
|
145
|
+
|
|
146
|
+
expect(current_bucket_stats["overall:misses"].to_f).to be >= 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "ignores payloads without key or name" do
|
|
150
|
+
payload = {hit: true, store: Rails.cache.class.name}
|
|
151
|
+
expect { described_class.call(nil, nil, nil, nil, payload) }.not_to raise_error
|
|
152
|
+
|
|
153
|
+
stats = Rails.cache.read(current_bucket_key)
|
|
154
|
+
|
|
155
|
+
expect(stats).to be_nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "ignores payloads from different store classes" do
|
|
159
|
+
payload = {key: "test_key", hit: true, store: "ActiveSupport::Cache::MemoryStore"}
|
|
160
|
+
# Only ignore if Rails.cache is not a MemoryStore
|
|
161
|
+
if Rails.cache.class.name != "ActiveSupport::Cache::MemoryStore"
|
|
162
|
+
described_class.call(nil, nil, nil, nil, payload)
|
|
163
|
+
|
|
164
|
+
sleep 0.1
|
|
165
|
+
|
|
166
|
+
stats = Rails.cache.read(current_bucket_key)
|
|
167
|
+
|
|
168
|
+
expect(stats).to be_nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "handles errors gracefully" do
|
|
173
|
+
allow_any_instance_of(CacheStache::CacheClient).to receive(:increment_stats).and_raise(StandardError, "Test error")
|
|
174
|
+
allow(Rails.logger).to receive(:error)
|
|
175
|
+
|
|
176
|
+
payload = {key: "test_key", hit: true, store: Rails.cache.class.name}
|
|
177
|
+
expect { described_class.call(nil, nil, nil, nil, payload) }.not_to raise_error
|
|
178
|
+
expect(Rails.logger).to have_received(:error).with(/instrumentation error/)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "logs error backtraces" do
|
|
182
|
+
allow_any_instance_of(CacheStache::CacheClient).to receive(:increment_stats).and_raise(StandardError, "Test error")
|
|
183
|
+
allow(Rails.logger).to receive(:error)
|
|
184
|
+
|
|
185
|
+
payload = {key: "test_key", hit: true, store: Rails.cache.class.name}
|
|
186
|
+
described_class.call(nil, nil, nil, nil, payload)
|
|
187
|
+
|
|
188
|
+
# Should log both the error message and the backtrace
|
|
189
|
+
expect(Rails.logger).to have_received(:error).twice
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
describe "bucket alignment" do
|
|
194
|
+
before do
|
|
195
|
+
described_class.install!
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "assigns operations to correct time buckets" do
|
|
199
|
+
Rails.cache.read("test_bucket_alignment") # miss
|
|
200
|
+
|
|
201
|
+
expect(current_bucket_stats["overall:misses"].to_f).to be >= 1
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "groups operations within the same bucket" do
|
|
205
|
+
Rails.cache.read("test_bucket_1") # miss
|
|
206
|
+
Rails.cache.read("test_bucket_2") # miss
|
|
207
|
+
Rails.cache.read("test_bucket_3") # miss
|
|
208
|
+
|
|
209
|
+
expect(current_bucket_stats["overall:misses"].to_f).to be >= 3
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
describe "sample_rate" do
|
|
214
|
+
it "reduces event volume when sample_rate is lower than 1.0" do
|
|
215
|
+
config.sample_rate = 0.5
|
|
216
|
+
described_class.install!
|
|
217
|
+
|
|
218
|
+
200.times { |i| Rails.cache.read("sample_test_#{i}") }
|
|
219
|
+
|
|
220
|
+
sleep 0.1
|
|
221
|
+
|
|
222
|
+
recorded_count = (current_bucket_stats["overall:misses"] || 0).to_f
|
|
223
|
+
|
|
224
|
+
expect(recorded_count).to be >= 80
|
|
225
|
+
expect(recorded_count).to be <= 120
|
|
226
|
+
expect(recorded_count).to be < 200
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "records all events when sample_rate is 1.0" do
|
|
230
|
+
config.sample_rate = 1.0
|
|
231
|
+
described_class.install!
|
|
232
|
+
|
|
233
|
+
50.times { |i| Rails.cache.read("full_sample_test_#{i}") }
|
|
234
|
+
|
|
235
|
+
sleep 0.1
|
|
236
|
+
|
|
237
|
+
recorded_count = (current_bucket_stats["overall:misses"] || 0).to_f
|
|
238
|
+
|
|
239
|
+
expect(recorded_count).to eq(50)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "applies sampling consistently across keyspaces" do
|
|
243
|
+
config.sample_rate = 0.5
|
|
244
|
+
described_class.install!
|
|
245
|
+
|
|
246
|
+
200.times { |i| Rails.cache.read("views/sample_test_#{i}") }
|
|
247
|
+
|
|
248
|
+
sleep 0.1
|
|
249
|
+
|
|
250
|
+
stats = current_bucket_stats
|
|
251
|
+
overall_count = (stats["overall:misses"] || 0).to_f
|
|
252
|
+
keyspace_count = (stats["views:misses"] || 0).to_f
|
|
253
|
+
|
|
254
|
+
expect(overall_count).to be >= 80
|
|
255
|
+
expect(overall_count).to be <= 120
|
|
256
|
+
expect(keyspace_count).to eq(overall_count)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/mock"
|
|
4
|
+
require "active_support/testing/time_helpers"
|
|
5
|
+
require_relative "../cache_stache_helper"
|
|
6
|
+
|
|
7
|
+
RSpec.describe "CacheStache rack.after_reply" do
|
|
8
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
9
|
+
|
|
10
|
+
let(:config) { build_test_config(use_rack_after_reply: true) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
allow(CacheStache).to receive(:configuration).and_return(config)
|
|
14
|
+
CacheStache::Instrumentation.install!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
after do
|
|
18
|
+
travel_back if respond_to?(:travel_back)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "records cache stats via a single after_reply proc" do
|
|
22
|
+
travel_to Time.utc(2020, 1, 1, 0, 0, 0) do
|
|
23
|
+
inner = lambda do |_env|
|
|
24
|
+
Rails.cache.write("test_key", "value")
|
|
25
|
+
Rails.cache.read("test_key") # hit
|
|
26
|
+
Rails.cache.read("missing_key") # miss
|
|
27
|
+
[200, {"Content-Type" => "text/plain"}, ["ok"]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
app = CacheStache::RackAfterReplyMiddleware.new(inner)
|
|
31
|
+
|
|
32
|
+
env = Rack::MockRequest.env_for("/", "rack.after_reply" => [])
|
|
33
|
+
status, = app.call(env)
|
|
34
|
+
expect(status).to eq(200)
|
|
35
|
+
|
|
36
|
+
expect(env["rack.after_reply"].size).to eq(1)
|
|
37
|
+
|
|
38
|
+
expect(cache_stache_redis.hgetall(current_bucket_key)).to be_empty
|
|
39
|
+
|
|
40
|
+
env["rack.after_reply"].each(&:call)
|
|
41
|
+
|
|
42
|
+
stats = current_bucket_stats
|
|
43
|
+
expect(stats["overall:hits"].to_f).to be >= 1
|
|
44
|
+
expect(stats["overall:misses"].to_f).to be >= 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "CacheStache rake tasks" do
|
|
6
|
+
it "loads without error and registers tasks" do
|
|
7
|
+
# Run `rake -P` from the dummy app to verify the railtie loads tasks correctly.
|
|
8
|
+
# If the railtie's rake task path is wrong, this will fail with LoadError.
|
|
9
|
+
dummy_app_path = File.expand_path("../dummy_app", __dir__)
|
|
10
|
+
output = `cd #{dummy_app_path} && bundle exec rake -P 2>&1`
|
|
11
|
+
|
|
12
|
+
expect($?.success?).to be(true), "rake -P failed:\n#{output}"
|
|
13
|
+
expect(output).to include("cache_stache:config")
|
|
14
|
+
expect(output).to include("cache_stache:prune")
|
|
15
|
+
expect(output).to include("cache_stache:stats")
|
|
16
|
+
end
|
|
17
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Only run SimpleCov when running CacheStache specs in isolation
|
|
4
|
+
# (not when loaded as part of the main app's test suite)
|
|
5
|
+
if ENV["CACHE_STACHE_COVERAGE"] || !defined?(Rails)
|
|
6
|
+
require "simplecov"
|
|
7
|
+
|
|
8
|
+
SimpleCov.start do
|
|
9
|
+
enable_coverage :branch
|
|
10
|
+
command_name "CacheStache"
|
|
11
|
+
|
|
12
|
+
# The project root for CacheStache is the lib/cache_stache directory
|
|
13
|
+
cache_stache_root = File.expand_path("..", __dir__)
|
|
14
|
+
root cache_stache_root
|
|
15
|
+
coverage_dir File.join(cache_stache_root, "coverage")
|
|
16
|
+
|
|
17
|
+
# Only track files within lib/cache_stache
|
|
18
|
+
add_filter do |source_file|
|
|
19
|
+
!source_file.filename.start_with?(cache_stache_root)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_filter "/spec/"
|
|
23
|
+
add_filter "/bin/"
|
|
24
|
+
add_filter "/tasks/"
|
|
25
|
+
add_filter "/generators/"
|
|
26
|
+
|
|
27
|
+
add_group "Core", ["cache_stache.rb", "configuration.rb", "keyspace.rb"]
|
|
28
|
+
add_group "Storage", ["cache_client.rb"]
|
|
29
|
+
add_group "Instrumentation", ["instrumentation.rb"]
|
|
30
|
+
add_group "Query", ["stats_query.rb"]
|
|
31
|
+
add_group "Web", ["engine.rb", "railtie.rb", "web.rb", "app/"]
|
|
32
|
+
|
|
33
|
+
# Coverage thresholds disabled for now - just tracking coverage
|
|
34
|
+
# minimum_coverage line: 80, branch: 60
|
|
35
|
+
# minimum_coverage_by_file line: 50, branch: 30
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
require "bundler/setup"
|
|
40
|
+
|
|
41
|
+
# Set up Rails environment and load the dummy app (which loads CacheStache)
|
|
42
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
43
|
+
require_relative "dummy_app/config/application"
|
|
44
|
+
|
|
45
|
+
RSpec.configure do |config|
|
|
46
|
+
# Enable flags like --only-failures and --next-failure
|
|
47
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
|
48
|
+
|
|
49
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
50
|
+
config.disable_monkey_patching!
|
|
51
|
+
|
|
52
|
+
config.expect_with :rspec do |c|
|
|
53
|
+
c.syntax = :expect
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Use consistent ordering for tests
|
|
57
|
+
config.order = :random
|
|
58
|
+
Kernel.srand config.seed
|
|
59
|
+
|
|
60
|
+
# Clear configuration before each test
|
|
61
|
+
config.before do
|
|
62
|
+
CacheStache.instance_variable_set(:@configuration, nil)
|
|
63
|
+
end
|
|
64
|
+
end
|