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,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_stache_helper"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
RSpec.describe CacheStache::CacheClient do
|
|
7
|
+
subject(:client) { described_class.new(config) }
|
|
8
|
+
|
|
9
|
+
let(:config) { build_test_config }
|
|
10
|
+
|
|
11
|
+
describe "#initialize" do
|
|
12
|
+
it "uses the provided configuration" do
|
|
13
|
+
expect(client.instance_variable_get(:@config)).to eq(config)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "uses the default configuration when none provided" do
|
|
17
|
+
allow(CacheStache).to receive(:configuration).and_return(config)
|
|
18
|
+
default_client = described_class.new
|
|
19
|
+
expect(default_client.instance_variable_get(:@config)).to eq(config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "creates a Redis connection pool" do
|
|
23
|
+
expect(client.instance_variable_get(:@pool)).to be_a(ConnectionPool)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe "#increment_stats" do
|
|
28
|
+
# Use aligned timestamp (1_700_000_000 / 300 * 300 = 1_699_999_800)
|
|
29
|
+
let(:bucket_ts) { (1_700_000_000 / 300) * 300 }
|
|
30
|
+
let(:increments) do
|
|
31
|
+
{
|
|
32
|
+
"overall:hits" => 1,
|
|
33
|
+
"overall:misses" => 0,
|
|
34
|
+
"views:hits" => 1,
|
|
35
|
+
"views:misses" => 0
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "increments stats for a bucket" do
|
|
40
|
+
client.increment_stats(bucket_ts, increments)
|
|
41
|
+
|
|
42
|
+
buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
|
|
43
|
+
expect(buckets.size).to eq(1)
|
|
44
|
+
expect(buckets.first[:stats]["overall:hits"]).to eq(1.0)
|
|
45
|
+
expect(buckets.first[:stats]["overall:misses"]).to eq(0.0)
|
|
46
|
+
expect(buckets.first[:stats]["views:hits"]).to eq(1.0)
|
|
47
|
+
expect(buckets.first[:stats]["views:misses"]).to eq(0.0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "accumulates stats across multiple calls" do
|
|
51
|
+
client.increment_stats(bucket_ts, increments)
|
|
52
|
+
client.increment_stats(bucket_ts, increments)
|
|
53
|
+
|
|
54
|
+
buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
|
|
55
|
+
expect(buckets.first[:stats]["overall:hits"]).to eq(2.0)
|
|
56
|
+
expect(buckets.first[:stats]["views:hits"]).to eq(2.0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "handles mixed hit and miss increments" do
|
|
60
|
+
client.increment_stats(bucket_ts, {"overall:hits" => 1, "overall:misses" => 0})
|
|
61
|
+
client.increment_stats(bucket_ts, {"overall:hits" => 0, "overall:misses" => 1})
|
|
62
|
+
|
|
63
|
+
buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
|
|
64
|
+
expect(buckets.first[:stats]["overall:hits"]).to eq(1.0)
|
|
65
|
+
expect(buckets.first[:stats]["overall:misses"]).to eq(1.0)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "sets expiry on the bucket" do
|
|
69
|
+
client.increment_stats(bucket_ts, increments)
|
|
70
|
+
|
|
71
|
+
key = "cache_stache:v1:test:#{bucket_ts}"
|
|
72
|
+
ttl = cache_stache_redis.ttl(key)
|
|
73
|
+
|
|
74
|
+
expect(ttl).to be > 0
|
|
75
|
+
expect(ttl).to be <= config.retention_seconds
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "handles errors gracefully" do
|
|
79
|
+
# Create client, then make the pool raise errors
|
|
80
|
+
test_client = described_class.new(config)
|
|
81
|
+
pool = test_client.instance_variable_get(:@pool)
|
|
82
|
+
allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
|
|
83
|
+
allow(Rails.logger).to receive(:error)
|
|
84
|
+
|
|
85
|
+
expect { test_client.increment_stats(bucket_ts, increments) }.not_to raise_error
|
|
86
|
+
expect(Rails.logger).to have_received(:error).with(/Failed to increment stats/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe "#fetch_buckets" do
|
|
91
|
+
# Use a timestamp that aligns to 300-second bucket boundaries
|
|
92
|
+
let(:base_ts) { (1_700_000_000 / 300) * 300 } # 1_699_999_800
|
|
93
|
+
|
|
94
|
+
before do
|
|
95
|
+
# Create some test buckets
|
|
96
|
+
client.increment_stats(base_ts, {"overall:hits" => 5, "overall:misses" => 2})
|
|
97
|
+
client.increment_stats(base_ts + 300, {"overall:hits" => 3, "overall:misses" => 1})
|
|
98
|
+
client.increment_stats(base_ts + 600, {"overall:hits" => 8, "overall:misses" => 4})
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "fetches buckets in time range" do
|
|
102
|
+
from_ts = base_ts
|
|
103
|
+
to_ts = base_ts + 700
|
|
104
|
+
|
|
105
|
+
buckets = client.fetch_buckets(from_ts, to_ts)
|
|
106
|
+
|
|
107
|
+
expect(buckets.size).to eq(3)
|
|
108
|
+
expect(buckets[0][:timestamp]).to eq(base_ts)
|
|
109
|
+
expect(buckets[1][:timestamp]).to eq(base_ts + 300)
|
|
110
|
+
expect(buckets[2][:timestamp]).to eq(base_ts + 600)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "returns bucket stats as floats" do
|
|
114
|
+
buckets = client.fetch_buckets(base_ts, base_ts + 100)
|
|
115
|
+
|
|
116
|
+
expect(buckets[0][:stats]["overall:hits"]).to eq(5.0)
|
|
117
|
+
expect(buckets[0][:stats]["overall:misses"]).to eq(2.0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "excludes empty buckets" do
|
|
121
|
+
buckets = client.fetch_buckets(base_ts + 900, base_ts + 1200)
|
|
122
|
+
expect(buckets).to be_empty
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "aligns timestamps to bucket boundaries" do
|
|
126
|
+
buckets = client.fetch_buckets(base_ts + 50, base_ts + 550)
|
|
127
|
+
|
|
128
|
+
expect(buckets.map { |b| b[:timestamp] }).to eq([base_ts, base_ts + 300])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "handles errors gracefully" do
|
|
132
|
+
# Create client, then make the pool raise errors
|
|
133
|
+
test_client = described_class.new(config)
|
|
134
|
+
pool = test_client.instance_variable_get(:@pool)
|
|
135
|
+
allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
|
|
136
|
+
allow(Rails.logger).to receive(:error)
|
|
137
|
+
|
|
138
|
+
buckets = test_client.fetch_buckets(base_ts, base_ts + 300)
|
|
139
|
+
expect(buckets).to eq([])
|
|
140
|
+
expect(Rails.logger).to have_received(:error).with(/Failed to fetch buckets/)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "reflects single-field increments" do
|
|
144
|
+
# Use a timestamp aligned to bucket boundary
|
|
145
|
+
ts = base_ts + 900
|
|
146
|
+
|
|
147
|
+
# Increment a single field
|
|
148
|
+
client.increment_stats(ts, {"overall:hits" => 1})
|
|
149
|
+
|
|
150
|
+
# Fetch the bucket containing this increment
|
|
151
|
+
buckets = client.fetch_buckets(ts, ts + 100)
|
|
152
|
+
|
|
153
|
+
# Verify the increment is visible
|
|
154
|
+
expect(buckets.size).to eq(1)
|
|
155
|
+
expect(buckets[0][:timestamp]).to eq(ts)
|
|
156
|
+
expect(buckets[0][:stats]["overall:hits"]).to eq(1.0)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe "#store_config_metadata" do
|
|
161
|
+
it "stores configuration metadata in Redis" do
|
|
162
|
+
client.store_config_metadata
|
|
163
|
+
|
|
164
|
+
metadata = client.fetch_config_metadata
|
|
165
|
+
|
|
166
|
+
expect(metadata["bucket_seconds"]).to eq(300)
|
|
167
|
+
expect(metadata["retention_seconds"]).to eq(3600)
|
|
168
|
+
expect(metadata["updated_at"]).to be_a(Integer)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "handles errors gracefully" do
|
|
172
|
+
# Create client, then make the pool raise errors
|
|
173
|
+
test_client = described_class.new(config)
|
|
174
|
+
pool = test_client.instance_variable_get(:@pool)
|
|
175
|
+
allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
|
|
176
|
+
allow(Rails.logger).to receive(:error)
|
|
177
|
+
|
|
178
|
+
expect { test_client.store_config_metadata }.not_to raise_error
|
|
179
|
+
expect(Rails.logger).to have_received(:error).with(/Failed to store config metadata/)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "#fetch_config_metadata" do
|
|
184
|
+
it "retrieves stored metadata" do
|
|
185
|
+
client.store_config_metadata
|
|
186
|
+
metadata = client.fetch_config_metadata
|
|
187
|
+
|
|
188
|
+
expect(metadata["bucket_seconds"]).to eq(300)
|
|
189
|
+
expect(metadata["retention_seconds"]).to eq(3600)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "returns nil when metadata doesn't exist" do
|
|
193
|
+
metadata = client.fetch_config_metadata
|
|
194
|
+
expect(metadata).to be_nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "handles errors gracefully" do
|
|
198
|
+
# Create client, then make the pool raise errors
|
|
199
|
+
test_client = described_class.new(config)
|
|
200
|
+
pool = test_client.instance_variable_get(:@pool)
|
|
201
|
+
allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
|
|
202
|
+
allow(Rails.logger).to receive(:error)
|
|
203
|
+
|
|
204
|
+
metadata = test_client.fetch_config_metadata
|
|
205
|
+
expect(metadata).to be_nil
|
|
206
|
+
expect(Rails.logger).to have_received(:error).with(/Failed to fetch config metadata/)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe "#estimate_storage_size" do
|
|
211
|
+
before do
|
|
212
|
+
config.keyspace(:views) { match(/^views\//) }
|
|
213
|
+
config.keyspace(:models) { match(/model/) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "calculates estimated storage size" do
|
|
217
|
+
estimate = client.estimate_storage_size
|
|
218
|
+
|
|
219
|
+
expect(estimate[:max_buckets]).to be > 0
|
|
220
|
+
expect(estimate[:fields_per_bucket]).to eq(6) # 2 overall + 2*2 keyspaces
|
|
221
|
+
expect(estimate[:bytes_per_bucket]).to be > 0
|
|
222
|
+
expect(estimate[:total_bytes]).to be > 0
|
|
223
|
+
expect(estimate[:human_size]).to be_a(String)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "formats bytes correctly" do
|
|
227
|
+
expect(client.send(:format_bytes, 500)).to eq("500 B")
|
|
228
|
+
expect(client.send(:format_bytes, 2048)).to eq("2.0 KB")
|
|
229
|
+
expect(client.send(:format_bytes, 2_097_152)).to eq("2.0 MB")
|
|
230
|
+
expect(client.send(:format_bytes, 2_147_483_648)).to eq("2.0 GB")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "handles errors gracefully" do
|
|
234
|
+
allow(config).to receive(:retention_seconds).and_raise(StandardError, "Config error")
|
|
235
|
+
expect(Rails.logger).to receive(:error).with(/Failed to estimate storage size/)
|
|
236
|
+
|
|
237
|
+
estimate = client.estimate_storage_size
|
|
238
|
+
expect(estimate[:total_bytes]).to eq(0)
|
|
239
|
+
expect(estimate[:human_size]).to eq("Unknown")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe "private methods" do
|
|
244
|
+
describe "#bucket_key" do
|
|
245
|
+
it "generates correct bucket key format" do
|
|
246
|
+
key = client.send(:bucket_key, 1_700_000_000)
|
|
247
|
+
expect(key).to eq("cache_stache:v1:test:1700000000")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
describe "#align_to_bucket" do
|
|
252
|
+
it "aligns timestamp to bucket boundary" do
|
|
253
|
+
aligned = client.send(:align_to_bucket, 1_700_000_123)
|
|
254
|
+
expect(aligned).to eq(1_700_000_100) # 123 aligned to 300s bucket
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
describe "#extract_timestamp_from_key" do
|
|
259
|
+
it "extracts timestamp from key" do
|
|
260
|
+
ts = client.send(:extract_timestamp_from_key, "cache_stache:v1:test:1700000000")
|
|
261
|
+
expect(ts).to eq(1_700_000_000)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
describe "#bucket_keys_in_range" do
|
|
266
|
+
it "generates all bucket keys in range" do
|
|
267
|
+
# Use aligned timestamps (1_699_999_800 is divisible by 300)
|
|
268
|
+
aligned_base = (1_700_000_000 / 300) * 300
|
|
269
|
+
keys = client.send(:bucket_keys_in_range, aligned_base, aligned_base + 700)
|
|
270
|
+
expect(keys).to eq([
|
|
271
|
+
"cache_stache:v1:test:#{aligned_base}",
|
|
272
|
+
"cache_stache:v1:test:#{aligned_base + 300}",
|
|
273
|
+
"cache_stache:v1:test:#{aligned_base + 600}"
|
|
274
|
+
])
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_stache_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CacheStache::Configuration do
|
|
6
|
+
subject(:config) { described_class.new }
|
|
7
|
+
|
|
8
|
+
describe "#initialize" do
|
|
9
|
+
let(:default_redis_url) { "redis://cache-stache.test/0" }
|
|
10
|
+
|
|
11
|
+
before do
|
|
12
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
13
|
+
allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return(default_redis_url)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it { expect(config.bucket_seconds).to eq(5.minutes.to_i) }
|
|
17
|
+
it { expect(config.retention_seconds).to eq(7.days.to_i) }
|
|
18
|
+
it { expect(config.sample_rate).to eq(1.0) }
|
|
19
|
+
it { expect(config.enabled).to be(true) }
|
|
20
|
+
it { expect(config.use_rack_after_reply).to be(false) }
|
|
21
|
+
it { expect(config.redis_url).to eq(default_redis_url) }
|
|
22
|
+
it { expect(config.redis_pool_size).to eq(5) }
|
|
23
|
+
it { expect(config.keyspaces).to eq([]) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe "#keyspace" do
|
|
27
|
+
it "adds a keyspace with the given name" do
|
|
28
|
+
config.keyspace(:views) do
|
|
29
|
+
label "View Fragments"
|
|
30
|
+
match(/^views\//)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
expect(config.keyspaces.size).to eq(1)
|
|
34
|
+
expect(config.keyspaces.first.name).to eq(:views)
|
|
35
|
+
expect(config.keyspaces.first.label).to eq("View Fragments")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "uses humanized name as default label" do
|
|
39
|
+
config.keyspace(:search_results) do
|
|
40
|
+
match(/search/)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
expect(config.keyspaces.first.label).to eq("Search results")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "validates the keyspace requires a pattern" do
|
|
47
|
+
expect do
|
|
48
|
+
config.keyspace(:invalid) do
|
|
49
|
+
label "Invalid"
|
|
50
|
+
# No match pattern
|
|
51
|
+
end
|
|
52
|
+
end.to raise_error(CacheStache::Error, /requires a match pattern/)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "validates the pattern must be a Regexp" do
|
|
56
|
+
expect do
|
|
57
|
+
config.keyspace(:invalid) do
|
|
58
|
+
match "not a regex"
|
|
59
|
+
end
|
|
60
|
+
end.to raise_error(CacheStache::Error, /requires a Regexp argument/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "raises error for duplicate keyspace names" do
|
|
64
|
+
config.keyspace(:views) do
|
|
65
|
+
match(/^views\//)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
expect do
|
|
69
|
+
config.keyspace(:views) do
|
|
70
|
+
match(/view/)
|
|
71
|
+
end
|
|
72
|
+
end.to raise_error(CacheStache::Error, /already defined/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "accepts a block without a label" do
|
|
76
|
+
config.keyspace(:test) do
|
|
77
|
+
match(/test/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
expect(config.keyspaces.first.label).to eq("Test")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "stores the pattern on the keyspace" do
|
|
84
|
+
config.keyspace(:views) do
|
|
85
|
+
match(/^views\//)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
expect(config.keyspaces.first.pattern).to eq(/^views\//)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe "#matching_keyspaces" do
|
|
93
|
+
before do
|
|
94
|
+
config.keyspace(:views) do
|
|
95
|
+
match(/^views\//)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
config.keyspace(:models) do
|
|
99
|
+
match(/community/)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
config.keyspace(:search) do
|
|
103
|
+
match(/search/)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "returns keyspaces matching the key" do
|
|
108
|
+
matches = config.matching_keyspaces("views/product_123")
|
|
109
|
+
expect(matches.map(&:name)).to eq([:views])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "returns multiple keyspaces when key matches multiple patterns" do
|
|
113
|
+
matches = config.matching_keyspaces("views/community_search")
|
|
114
|
+
expect(matches.map(&:name)).to contain_exactly(:views, :models, :search)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "returns empty array when no keyspaces match" do
|
|
118
|
+
matches = config.matching_keyspaces("unmatched_key")
|
|
119
|
+
expect(matches).to eq([])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "caches results for the same key" do
|
|
123
|
+
# First call
|
|
124
|
+
config.matching_keyspaces("views/test")
|
|
125
|
+
|
|
126
|
+
# Modify keyspace to prove caching
|
|
127
|
+
config.keyspaces.first.instance_variable_set(:@pattern, /never_match/)
|
|
128
|
+
|
|
129
|
+
# Should still return cached result
|
|
130
|
+
matches = config.matching_keyspaces("views/test")
|
|
131
|
+
expect(matches.map(&:name)).to eq([:views])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe "#validate!" do
|
|
136
|
+
before do
|
|
137
|
+
config.redis_url = "redis://localhost:6379/0"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "requires redis_url" do
|
|
141
|
+
config.redis_url = nil
|
|
142
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /redis_url must be configured/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "requires redis_pool_size to be positive" do
|
|
146
|
+
config.redis_pool_size = 0
|
|
147
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /redis_pool_size must be positive/)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "validates bucket_seconds is positive" do
|
|
151
|
+
config.bucket_seconds = 0
|
|
152
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /bucket_seconds must be positive/)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "validates retention_seconds is positive" do
|
|
156
|
+
config.retention_seconds = -1
|
|
157
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /retention_seconds must be positive/)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "validates sample_rate is between 0 and 1" do
|
|
161
|
+
config.sample_rate = 1.5
|
|
162
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /sample_rate must be between 0 and 1/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "validates all keyspaces" do
|
|
166
|
+
# Create a keyspace with a valid pattern first
|
|
167
|
+
config.keyspace(:invalid) do
|
|
168
|
+
match(/test/)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Then corrupt it by setting pattern to nil
|
|
172
|
+
config.instance_variable_get(:@keyspaces).last.instance_variable_set(:@pattern, nil)
|
|
173
|
+
|
|
174
|
+
expect { config.validate! }.to raise_error(CacheStache::Error, /requires a match pattern/)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "warns when retention doesn't divide evenly by bucket size" do
|
|
178
|
+
config.bucket_seconds = 7.minutes.to_i
|
|
179
|
+
config.retention_seconds = 1.hour.to_i
|
|
180
|
+
|
|
181
|
+
expect(Rails.logger).to receive(:warn).with(/does not divide evenly/)
|
|
182
|
+
config.validate!
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "passes validation with valid configuration" do
|
|
186
|
+
config.keyspace(:views) do
|
|
187
|
+
match(/^views\//)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
expect { config.validate! }.not_to raise_error
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe "#rails_env" do
|
|
195
|
+
it "returns RAILS_ENV when set" do
|
|
196
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
197
|
+
allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return("redis://cache-stache.test/0")
|
|
198
|
+
allow(ENV).to receive(:fetch).with("RAILS_ENV", "development").and_return("production")
|
|
199
|
+
expect(config.rails_env).to eq("production")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "defaults to development when RAILS_ENV is not set" do
|
|
203
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
204
|
+
allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return("redis://cache-stache.test/0")
|
|
205
|
+
allow(ENV).to receive(:fetch).with("RAILS_ENV", "development").and_return("development")
|
|
206
|
+
expect(config.rails_env).to eq("development")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_stache_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CacheStache::Keyspace do
|
|
6
|
+
subject(:keyspace) { described_class.new(:views) }
|
|
7
|
+
|
|
8
|
+
describe "#initialize" do
|
|
9
|
+
it { expect(keyspace.name).to eq(:views) }
|
|
10
|
+
it { expect(keyspace.label).to eq("Views") }
|
|
11
|
+
it { expect(keyspace.pattern).to be_nil }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe "#match?" do
|
|
15
|
+
context "with a valid pattern" do
|
|
16
|
+
before do
|
|
17
|
+
keyspace.pattern = /^views\//
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "returns true when key matches" do
|
|
21
|
+
expect(keyspace.match?("views/product_123")).to be(true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "returns false when key doesn't match" do
|
|
25
|
+
expect(keyspace.match?("community/456")).to be(false)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
context "without a pattern" do
|
|
30
|
+
it "returns false" do
|
|
31
|
+
expect(keyspace.match?("any_key")).to be(false)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context "with various regex patterns" do
|
|
36
|
+
it "handles start anchor" do
|
|
37
|
+
keyspace.pattern = /^views\/\w+_\d+$/
|
|
38
|
+
expect(keyspace.match?("views/product_123")).to be(true)
|
|
39
|
+
expect(keyspace.match?("views/invalid")).to be(false)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "handles partial matching" do
|
|
43
|
+
keyspace.pattern = /search/
|
|
44
|
+
expect(keyspace.match?("pages/search/results")).to be(true)
|
|
45
|
+
expect(keyspace.match?("pages/home")).to be(false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "handles alternation" do
|
|
49
|
+
keyspace.pattern = %r{/(community|unit|lease)/}
|
|
50
|
+
expect(keyspace.match?("models/community/123")).to be(true)
|
|
51
|
+
expect(keyspace.match?("models/unit/456")).to be(true)
|
|
52
|
+
expect(keyspace.match?("models/account/789")).to be(false)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe "#validate!" do
|
|
58
|
+
it "raises error when pattern is not set" do
|
|
59
|
+
expect do
|
|
60
|
+
keyspace.validate!
|
|
61
|
+
end.to raise_error(CacheStache::Error, /Keyspace views requires a match pattern/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "raises error when pattern is not a Regexp" do
|
|
65
|
+
keyspace.pattern = "not a regex"
|
|
66
|
+
expect do
|
|
67
|
+
keyspace.validate!
|
|
68
|
+
end.to raise_error(CacheStache::Error, /match pattern must be a Regexp/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "passes validation when pattern is a valid Regexp" do
|
|
72
|
+
keyspace.pattern = /test/
|
|
73
|
+
expect { keyspace.validate! }.not_to raise_error
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe "label humanization" do
|
|
78
|
+
it "humanizes snake_case names" do
|
|
79
|
+
ks = described_class.new(:search_results)
|
|
80
|
+
expect(ks.label).to eq("Search results")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "humanizes single word names" do
|
|
84
|
+
ks = described_class.new(:profiles)
|
|
85
|
+
expect(ks.label).to eq("Profiles")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "can be overridden" do
|
|
89
|
+
keyspace.label = "Custom Label"
|
|
90
|
+
expect(keyspace.label).to eq("Custom Label")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|