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,83 @@
|
|
|
1
|
+
<p><small><%= link_to "← Back to Dashboard", root_path(window: params[:window]) %></small></p>
|
|
2
|
+
|
|
3
|
+
<div class="grid">
|
|
4
|
+
<div>
|
|
5
|
+
<%= form_with url: keyspace_path(@keyspace.name), method: :get, local: true do |f| %>
|
|
6
|
+
<label for="window">
|
|
7
|
+
Time Window:
|
|
8
|
+
<%= f.select :window, window_options, { selected: params[:window] || "1h" }, { onchange: "this.form.submit();" } %>
|
|
9
|
+
</label>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<div>
|
|
13
|
+
<small>Last updated: <%= Time.current.strftime("%Y-%m-%d %H:%M:%S %Z") %></small>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<article>
|
|
18
|
+
<header>
|
|
19
|
+
<h2><%= @keyspace.label %></h2>
|
|
20
|
+
<p><small>Pattern: <code><%= @keyspace.pattern.inspect %></code></small></p>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<div role="group">
|
|
24
|
+
<article>
|
|
25
|
+
<small>Hit Rate</small>
|
|
26
|
+
<h3><mark><%= number_to_percentage(@keyspace_stats[:hit_rate_percent], precision: 1) %></mark></h3>
|
|
27
|
+
</article>
|
|
28
|
+
<article>
|
|
29
|
+
<small>Total Operations</small>
|
|
30
|
+
<h3><%= number_with_delimiter(@keyspace_stats[:total_operations]) %></h3>
|
|
31
|
+
</article>
|
|
32
|
+
<article>
|
|
33
|
+
<small>Cache Hits</small>
|
|
34
|
+
<h3><%= number_with_delimiter(@keyspace_stats[:hits]) %></h3>
|
|
35
|
+
</article>
|
|
36
|
+
<article>
|
|
37
|
+
<small>Cache Misses</small>
|
|
38
|
+
<h3><%= number_with_delimiter(@keyspace_stats[:misses]) %></h3>
|
|
39
|
+
</article>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<footer>
|
|
43
|
+
<small>
|
|
44
|
+
Showing data for the last <%= current_window_label %>
|
|
45
|
+
(<%= @results[:bucket_count] %> buckets of <%= @config.bucket_seconds / 60 %> minutes each)
|
|
46
|
+
</small>
|
|
47
|
+
</footer>
|
|
48
|
+
</article>
|
|
49
|
+
|
|
50
|
+
<article>
|
|
51
|
+
<header>
|
|
52
|
+
<h2>Hit Rate Over Time</h2>
|
|
53
|
+
</header>
|
|
54
|
+
<% sparkline = sparkline_data(@results[:buckets], @keyspace.name) %>
|
|
55
|
+
<% if sparkline.any? %>
|
|
56
|
+
<figure>
|
|
57
|
+
<table>
|
|
58
|
+
<thead>
|
|
59
|
+
<tr>
|
|
60
|
+
<th>Time (<%= Time.current.strftime("%Z") %>)</th>
|
|
61
|
+
<th>Hit Rate</th>
|
|
62
|
+
<th>Operations</th>
|
|
63
|
+
<th style="width: 100px;"></th>
|
|
64
|
+
</tr>
|
|
65
|
+
</thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
<% sparkline.each do |point| %>
|
|
68
|
+
<tr>
|
|
69
|
+
<td><%= Time.parse(point[:time]).strftime("%H:%M:%S") %></td>
|
|
70
|
+
<td><%= number_to_percentage(point[:hit_rate], precision: 1) %></td>
|
|
71
|
+
<td><%= number_with_delimiter(point[:operations]) %></td>
|
|
72
|
+
<td>
|
|
73
|
+
<progress value="<%= point[:hit_rate] %>" max="100"><%= point[:hit_rate] %>%</progress>
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
<% end %>
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</figure>
|
|
80
|
+
<% else %>
|
|
81
|
+
<p><small>No data available for this time period.</small></p>
|
|
82
|
+
<% end %>
|
|
83
|
+
</article>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>CacheStache - Cache Hit Rate Insights</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= stylesheet_link_tag "cache_stache/pico", media: "all" %>
|
|
7
|
+
<%= stylesheet_link_tag "cache_stache/application", media: "all" %>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main>
|
|
11
|
+
<%= yield %>
|
|
12
|
+
</main>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "redis"
|
|
4
|
+
require "connection_pool"
|
|
5
|
+
|
|
6
|
+
module CacheStache
|
|
7
|
+
class CacheClient
|
|
8
|
+
# Lua script for atomic increment with expiry
|
|
9
|
+
INCR_AND_EXPIRE_SCRIPT = <<~LUA
|
|
10
|
+
local key = KEYS[1]
|
|
11
|
+
local expire_seconds = tonumber(ARGV[1])
|
|
12
|
+
local increments = cjson.decode(ARGV[2])
|
|
13
|
+
|
|
14
|
+
for field, value in pairs(increments) do
|
|
15
|
+
redis.call('HINCRBYFLOAT', key, field, value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
local ttl = redis.call('TTL', key)
|
|
19
|
+
if ttl == -1 or ttl < expire_seconds then
|
|
20
|
+
redis.call('EXPIRE', key, expire_seconds)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
return redis.status_reply('OK')
|
|
24
|
+
LUA
|
|
25
|
+
|
|
26
|
+
def initialize(config = CacheStache.configuration)
|
|
27
|
+
@config = config
|
|
28
|
+
@pool = ConnectionPool.new(size: @config.redis_pool_size) do
|
|
29
|
+
Redis.new(url: @config.redis_url)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def increment_stats(bucket_ts, increments)
|
|
34
|
+
key = bucket_key(bucket_ts)
|
|
35
|
+
|
|
36
|
+
without_instrumentation do
|
|
37
|
+
@pool.with do |redis|
|
|
38
|
+
Rails.logger.debug { "CacheStache: Redis EVAL increment on #{key} with #{increments.size} fields" }
|
|
39
|
+
redis.eval(
|
|
40
|
+
INCR_AND_EXPIRE_SCRIPT,
|
|
41
|
+
keys: [key],
|
|
42
|
+
argv: [@config.retention_seconds, increments.to_json]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
rescue => e
|
|
47
|
+
Rails.logger.error("CacheStache: Failed to increment stats: #{e.message}")
|
|
48
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch_buckets(from_ts, to_ts)
|
|
52
|
+
keys = bucket_keys_in_range(from_ts, to_ts)
|
|
53
|
+
return [] if keys.empty?
|
|
54
|
+
|
|
55
|
+
Rails.logger.debug { "CacheStache: Redis fetching #{keys.size} buckets from #{from_ts} to #{to_ts}" }
|
|
56
|
+
|
|
57
|
+
without_instrumentation do
|
|
58
|
+
@pool.with do |redis|
|
|
59
|
+
Rails.logger.debug { "CacheStache: Redis PIPELINE hgetall for #{keys.size} keys" }
|
|
60
|
+
pipeline_results = redis.pipelined do |pipe|
|
|
61
|
+
keys.each { |key| pipe.hgetall(key) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
keys.zip(pipeline_results).map do |key, data|
|
|
65
|
+
next unless data && !data.empty?
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
timestamp: extract_timestamp_from_key(key),
|
|
69
|
+
stats: data.transform_values(&:to_f)
|
|
70
|
+
}
|
|
71
|
+
end.compact
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
rescue => e
|
|
75
|
+
Rails.logger.error("CacheStache: Failed to fetch buckets: #{e.message}")
|
|
76
|
+
[]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def store_config_metadata
|
|
80
|
+
key = "cache_stache:v1:#{@config.rails_env}:config"
|
|
81
|
+
metadata = {
|
|
82
|
+
bucket_seconds: @config.bucket_seconds,
|
|
83
|
+
retention_seconds: @config.retention_seconds,
|
|
84
|
+
updated_at: Time.current.to_i
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
without_instrumentation do
|
|
88
|
+
@pool.with do |redis|
|
|
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)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rescue => e
|
|
95
|
+
Rails.logger.error("CacheStache: Failed to store config metadata: #{e.message}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch_config_metadata
|
|
99
|
+
key = "cache_stache:v1:#{@config.rails_env}:config"
|
|
100
|
+
|
|
101
|
+
without_instrumentation do
|
|
102
|
+
@pool.with do |redis|
|
|
103
|
+
Rails.logger.debug { "CacheStache: Redis GET #{key}" }
|
|
104
|
+
data = redis.get(key)
|
|
105
|
+
data ? JSON.parse(data) : nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
rescue => e
|
|
109
|
+
Rails.logger.error("CacheStache: Failed to fetch config metadata: #{e.message}")
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def estimate_storage_size
|
|
114
|
+
# Calculate theoretical number of buckets
|
|
115
|
+
max_buckets = (@config.retention_seconds.to_f / @config.bucket_seconds).ceil
|
|
116
|
+
|
|
117
|
+
# Each bucket has:
|
|
118
|
+
# - overall:hits and overall:misses (2 fields)
|
|
119
|
+
# - keyspace_name:hits and keyspace_name:misses per keyspace (2 * num_keyspaces)
|
|
120
|
+
fields_per_bucket = 2 + (@config.keyspaces.size * 2)
|
|
121
|
+
|
|
122
|
+
# Estimate bytes per field:
|
|
123
|
+
# - Field name: ~20 bytes average (e.g., "search:hits", "profiles:misses")
|
|
124
|
+
# - Field value: ~8 bytes (float stored as string, e.g., "12345.0")
|
|
125
|
+
# - Redis hash overhead: ~24 bytes per field
|
|
126
|
+
bytes_per_field = 52
|
|
127
|
+
|
|
128
|
+
# Key overhead: "cache_stache:v1:environment:timestamp" ~45 bytes
|
|
129
|
+
# Plus Redis key overhead: ~96 bytes
|
|
130
|
+
key_overhead = 141
|
|
131
|
+
|
|
132
|
+
# Calculate total size per bucket
|
|
133
|
+
bytes_per_bucket = (fields_per_bucket * bytes_per_field) + key_overhead
|
|
134
|
+
|
|
135
|
+
# Total estimated size
|
|
136
|
+
total_bytes = max_buckets * bytes_per_bucket
|
|
137
|
+
|
|
138
|
+
# Add config metadata key size (~200 bytes)
|
|
139
|
+
total_bytes += 200
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
max_buckets: max_buckets,
|
|
143
|
+
fields_per_bucket: fields_per_bucket,
|
|
144
|
+
bytes_per_bucket: bytes_per_bucket,
|
|
145
|
+
total_bytes: total_bytes,
|
|
146
|
+
human_size: format_bytes(total_bytes)
|
|
147
|
+
}
|
|
148
|
+
rescue => e
|
|
149
|
+
Rails.logger.error("CacheStache: Failed to estimate storage size: #{e.message}")
|
|
150
|
+
{total_bytes: 0, human_size: "Unknown"}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def format_bytes(bytes)
|
|
156
|
+
if bytes < 1024
|
|
157
|
+
"#{bytes} B"
|
|
158
|
+
elsif bytes < 1024 * 1024
|
|
159
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
160
|
+
elsif bytes < 1024 * 1024 * 1024
|
|
161
|
+
"#{(bytes / (1024.0 * 1024)).round(2)} MB"
|
|
162
|
+
else
|
|
163
|
+
"#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def bucket_key(timestamp)
|
|
168
|
+
"cache_stache:v1:#{@config.rails_env}:#{timestamp}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def bucket_keys_in_range(from_ts, to_ts)
|
|
172
|
+
timestamps = []
|
|
173
|
+
current = align_to_bucket(from_ts)
|
|
174
|
+
to_aligned = align_to_bucket(to_ts)
|
|
175
|
+
|
|
176
|
+
while current <= to_aligned
|
|
177
|
+
timestamps << current
|
|
178
|
+
current += @config.bucket_seconds
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Limit to most recent max_buckets
|
|
182
|
+
if timestamps.size > @config.max_buckets
|
|
183
|
+
Rails.logger.warn("CacheStache: Truncating bucket range from #{timestamps.size} to #{@config.max_buckets} buckets (requested #{from_ts} to #{to_ts})")
|
|
184
|
+
timestamps = timestamps.last(@config.max_buckets)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
timestamps.map { |ts| bucket_key(ts) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def align_to_bucket(timestamp)
|
|
191
|
+
(timestamp.to_i / @config.bucket_seconds) * @config.bucket_seconds
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def extract_timestamp_from_key(key)
|
|
195
|
+
key.split(":").last.to_i
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def without_instrumentation(&block)
|
|
199
|
+
CacheStache::Instrumentation.without_instrumentation(&block)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
|
|
6
|
+
module CacheStache
|
|
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
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@bucket_seconds = 5.minutes.to_i
|
|
14
|
+
@retention_seconds = 7.days.to_i
|
|
15
|
+
@sample_rate = 1.0
|
|
16
|
+
@enabled = rails_env != "test"
|
|
17
|
+
@use_rack_after_reply = false
|
|
18
|
+
@redis_url = ENV.fetch("CACHE_STACHE_REDIS_URL") { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
19
|
+
@redis_pool_size = 5
|
|
20
|
+
@max_buckets = 288
|
|
21
|
+
@keyspaces = []
|
|
22
|
+
@keyspace_cache = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def keyspace(name, &block)
|
|
26
|
+
ks = Keyspace.new(name)
|
|
27
|
+
builder = KeyspaceBuilder.new(ks)
|
|
28
|
+
builder.instance_eval(&block) if block_given?
|
|
29
|
+
ks.validate!
|
|
30
|
+
|
|
31
|
+
raise Error, "Keyspace #{name} already defined" if @keyspaces.any? { |k| k.name == name }
|
|
32
|
+
|
|
33
|
+
@keyspaces << ks
|
|
34
|
+
ks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def matching_keyspaces(key)
|
|
38
|
+
# Simple memoization per key to avoid repeated block execution
|
|
39
|
+
cache_key = key_digest(key)
|
|
40
|
+
@keyspace_cache[cache_key] ||= @keyspaces.select { |ks| ks.match?(key) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate!
|
|
44
|
+
raise Error, "bucket_seconds must be positive" unless bucket_seconds.to_i.positive?
|
|
45
|
+
raise Error, "retention_seconds must be positive" unless retention_seconds.to_i.positive?
|
|
46
|
+
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
|
+
raise Error, "sample_rate must be between 0 and 1" unless sample_rate&.between?(0, 1)
|
|
49
|
+
raise Error, "max_buckets must be positive" unless max_buckets.to_i.positive?
|
|
50
|
+
|
|
51
|
+
if retention_seconds % bucket_seconds != 0
|
|
52
|
+
Rails.logger.warn(
|
|
53
|
+
"CacheStache: retention_seconds (#{retention_seconds}) does not divide evenly " \
|
|
54
|
+
"by bucket_seconds (#{bucket_seconds}). This may result in partial bucket retention."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@keyspaces.each(&:validate!)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def rails_env
|
|
62
|
+
@rails_env ||= ENV.fetch("RAILS_ENV", "development")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def key_digest(key)
|
|
68
|
+
# Use last 4 chars of a simple hash as cache key
|
|
69
|
+
Digest::MD5.hexdigest(key.to_s)[-4..]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class KeyspaceBuilder
|
|
73
|
+
def initialize(keyspace)
|
|
74
|
+
@keyspace = keyspace
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def label(value)
|
|
78
|
+
@keyspace.label = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def match(regex)
|
|
82
|
+
raise Error, "match requires a Regexp argument, got #{regex.class}" unless regex.is_a?(Regexp)
|
|
83
|
+
@keyspace.pattern = regex
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "rails/engine"
|
|
5
|
+
|
|
6
|
+
module CacheStache
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace CacheStache
|
|
9
|
+
|
|
10
|
+
# Engine root is lib/cache_stache/
|
|
11
|
+
config.root = Pathname.new(File.expand_path("../..", __dir__))
|
|
12
|
+
|
|
13
|
+
initializer "cache_stache.assets.precompile" do |app|
|
|
14
|
+
app.config.assets.precompile += %w[cache_stache/pico.css cache_stache/application.css]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CacheStache
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Thread-local key to track when CacheStache is performing internal operations
|
|
6
|
+
INTERNAL_OPERATION_KEY = :cache_stache_internal_operation
|
|
7
|
+
AFTER_REPLY_QUEUE_KEY = :cache_stache_after_reply_queue
|
|
8
|
+
MAX_AFTER_REPLY_EVENTS = 1000
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :monitored_store_class
|
|
12
|
+
|
|
13
|
+
def reset!
|
|
14
|
+
@installed = false
|
|
15
|
+
@monitored_store_class = nil
|
|
16
|
+
@cache_client = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def install!
|
|
20
|
+
return unless CacheStache.configuration.enabled
|
|
21
|
+
return if @installed
|
|
22
|
+
|
|
23
|
+
# Store cache_client as a class instance variable
|
|
24
|
+
@cache_client = CacheClient.new
|
|
25
|
+
@cache_client.store_config_metadata
|
|
26
|
+
|
|
27
|
+
# Capture the Rails.cache store class name to filter events.
|
|
28
|
+
# Note: This filters by class name, not instance. If multiple stores
|
|
29
|
+
# use the same class (e.g., two RedisCacheStore instances), events
|
|
30
|
+
# from all of them will be tracked.
|
|
31
|
+
@monitored_store_class = Rails.cache.class.name
|
|
32
|
+
|
|
33
|
+
# Subscribe to cache read events only (hits and misses)
|
|
34
|
+
ActiveSupport::Notifications.subscribe("cache_read.active_support", self)
|
|
35
|
+
|
|
36
|
+
@installed = true
|
|
37
|
+
Rails.logger.info("CacheStache: Instrumentation installed for #{@monitored_store_class}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Execute a block while marking it as an internal CacheStache operation.
|
|
41
|
+
# Cache operations inside this block will be ignored by instrumentation.
|
|
42
|
+
def without_instrumentation
|
|
43
|
+
previous_value = Thread.current[INTERNAL_OPERATION_KEY]
|
|
44
|
+
Thread.current[INTERNAL_OPERATION_KEY] = true
|
|
45
|
+
yield
|
|
46
|
+
ensure
|
|
47
|
+
Thread.current[INTERNAL_OPERATION_KEY] = previous_value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns true if we're currently inside an internal CacheStache operation
|
|
51
|
+
def internal_operation?
|
|
52
|
+
Thread.current[INTERNAL_OPERATION_KEY] == true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(_name, _start, _finish, _id, payload)
|
|
56
|
+
# Skip if this is an internal CacheStache operation
|
|
57
|
+
return if internal_operation?
|
|
58
|
+
|
|
59
|
+
# Only track events from Rails.cache, not other ActiveSupport::Cache instances
|
|
60
|
+
return unless payload[:store] == @monitored_store_class
|
|
61
|
+
|
|
62
|
+
key = payload[:key] || payload[:name]
|
|
63
|
+
return unless key
|
|
64
|
+
# Belt-and-suspenders: also skip by key prefix in case thread-local wasn't set
|
|
65
|
+
return if key.to_s.start_with?("cache_stache:")
|
|
66
|
+
|
|
67
|
+
# Skip event based on sample_rate (e.g., 0.5 means record 50% of events)
|
|
68
|
+
sample_rate = CacheStache.configuration.sample_rate
|
|
69
|
+
return if sample_rate < 1.0 && rand >= sample_rate
|
|
70
|
+
|
|
71
|
+
# Record hit or miss
|
|
72
|
+
bucket_ts = (Time.current.to_i / CacheStache.configuration.bucket_seconds) * CacheStache.configuration.bucket_seconds
|
|
73
|
+
hit = payload[:hit]
|
|
74
|
+
|
|
75
|
+
increments = {
|
|
76
|
+
"overall:hits" => hit ? 1 : 0,
|
|
77
|
+
"overall:misses" => hit ? 0 : 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Add keyspace increments
|
|
81
|
+
matching_keyspaces = CacheStache.configuration.matching_keyspaces(key)
|
|
82
|
+
matching_keyspaces.each do |keyspace|
|
|
83
|
+
increments["#{keyspace.name}:hits"] = hit ? 1 : 0
|
|
84
|
+
increments["#{keyspace.name}:misses"] = hit ? 0 : 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Filter out zero-valued fields to reduce write amplification
|
|
88
|
+
increments.reject! { |_k, v| v == 0 }
|
|
89
|
+
|
|
90
|
+
if should_defer_instrumentation?
|
|
91
|
+
enqueue_after_reply_event(bucket_ts, increments)
|
|
92
|
+
else
|
|
93
|
+
@cache_client.increment_stats(bucket_ts, increments)
|
|
94
|
+
end
|
|
95
|
+
rescue => e
|
|
96
|
+
Rails.logger.error("CacheStache instrumentation error: #{e.class}: #{e.message}")
|
|
97
|
+
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def flush_after_reply_queue!
|
|
101
|
+
return unless @cache_client
|
|
102
|
+
queue = Thread.current[AFTER_REPLY_QUEUE_KEY]
|
|
103
|
+
return unless queue.is_a?(Array) && !queue.empty?
|
|
104
|
+
|
|
105
|
+
max = MAX_AFTER_REPLY_EVENTS
|
|
106
|
+
dropped = [queue.size - max, 0].max
|
|
107
|
+
events = queue.shift([queue.size, max].min)
|
|
108
|
+
queue.clear
|
|
109
|
+
|
|
110
|
+
Rails.logger.warn("CacheStache: Dropped #{dropped} after-reply events") if dropped.positive?
|
|
111
|
+
|
|
112
|
+
combined = {}
|
|
113
|
+
events.each do |(bucket_ts, increments)|
|
|
114
|
+
combined[bucket_ts] ||= Hash.new(0)
|
|
115
|
+
increments.each do |field, value|
|
|
116
|
+
combined[bucket_ts][field] += value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
combined.each do |bucket_ts, increments|
|
|
121
|
+
# Filter out zero-valued fields to reduce write amplification
|
|
122
|
+
increments.reject! { |_k, v| v == 0 }
|
|
123
|
+
@cache_client.increment_stats(bucket_ts, increments)
|
|
124
|
+
end
|
|
125
|
+
rescue => e
|
|
126
|
+
Rails.logger.error("CacheStache after-reply flush error: #{e.class}: #{e.message}")
|
|
127
|
+
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def should_defer_instrumentation?
|
|
133
|
+
CacheStache.configuration.use_rack_after_reply
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def enqueue_after_reply_event(bucket_ts, increments)
|
|
137
|
+
Thread.current[AFTER_REPLY_QUEUE_KEY] ||= []
|
|
138
|
+
Thread.current[AFTER_REPLY_QUEUE_KEY] << [bucket_ts, increments]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module CacheStache
|
|
6
|
+
class Keyspace
|
|
7
|
+
attr_accessor :name, :label, :pattern
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@label = name.to_s.humanize
|
|
12
|
+
@pattern = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match?(key)
|
|
16
|
+
return false unless pattern
|
|
17
|
+
pattern.match?(key.to_s)
|
|
18
|
+
rescue => e
|
|
19
|
+
Rails.logger.error("CacheStache: Keyspace #{name} matcher error: #{e.message}")
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!
|
|
24
|
+
raise Error, "Keyspace #{name} requires a match pattern (regex)" unless pattern
|
|
25
|
+
raise Error, "Keyspace #{name} match pattern must be a Regexp" unless pattern.is_a?(Regexp)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CacheStache
|
|
4
|
+
class RackAfterReplyMiddleware
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
return @app.call(env) unless CacheStache.configuration.use_rack_after_reply
|
|
11
|
+
|
|
12
|
+
Thread.current[CacheStache::Instrumentation::AFTER_REPLY_QUEUE_KEY] = []
|
|
13
|
+
|
|
14
|
+
env["rack.after_reply"] ||= []
|
|
15
|
+
env["rack.after_reply"] << lambda do
|
|
16
|
+
CacheStache::Instrumentation.flush_after_reply_queue!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@app.call(env)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CacheStache
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "cache_stache.middleware", after: :load_config_initializers do |app|
|
|
6
|
+
if CacheStache.configuration.use_rack_after_reply
|
|
7
|
+
app.config.middleware.use(CacheStache::RackAfterReplyMiddleware)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "cache_stache.instrumentation", after: :load_config_initializers do
|
|
12
|
+
# Install instrumentation after initializers run so user configuration is loaded
|
|
13
|
+
if CacheStache.configuration.enabled
|
|
14
|
+
CacheStache::Instrumentation.install!
|
|
15
|
+
else
|
|
16
|
+
Rails.logger.info("CacheStache: Instrumentation disabled via configuration")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rake_tasks do
|
|
21
|
+
load File.expand_path("../../tasks/cache_stache.rake", __dir__)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer "cache_stache.web_reloader" do
|
|
25
|
+
ActiveSupport::Reloader.to_prepare do
|
|
26
|
+
CacheStache::Web.reset_routes!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|