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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +231 -0
  3. data/app/assets/stylesheets/cache_stache/application.css +5 -0
  4. data/app/assets/stylesheets/cache_stache/pico.css +4 -0
  5. data/app/controllers/cache_stache/application_controller.rb +11 -0
  6. data/app/controllers/cache_stache/dashboard_controller.rb +32 -0
  7. data/app/helpers/cache_stache/application_helper.rb +37 -0
  8. data/app/views/cache_stache/dashboard/index.html.erb +154 -0
  9. data/app/views/cache_stache/dashboard/keyspace.html.erb +83 -0
  10. data/app/views/layouts/cache_stache/application.html.erb +14 -0
  11. data/config/routes.rb +6 -0
  12. data/lib/cache_stache/cache_client.rb +202 -0
  13. data/lib/cache_stache/configuration.rb +87 -0
  14. data/lib/cache_stache/engine.rb +17 -0
  15. data/lib/cache_stache/instrumentation.rb +142 -0
  16. data/lib/cache_stache/keyspace.rb +28 -0
  17. data/lib/cache_stache/rack_after_reply_middleware.rb +22 -0
  18. data/lib/cache_stache/railtie.rb +30 -0
  19. data/lib/cache_stache/stats_query.rb +89 -0
  20. data/lib/cache_stache/version.rb +5 -0
  21. data/lib/cache_stache/web.rb +69 -0
  22. data/lib/cache_stache/window_options.rb +34 -0
  23. data/lib/cache_stache.rb +37 -0
  24. data/lib/generators/cache_stache/install_generator.rb +21 -0
  25. data/lib/generators/cache_stache/templates/README +35 -0
  26. data/lib/generators/cache_stache/templates/cache_stache.rb +43 -0
  27. data/spec/cache_stache_helper.rb +148 -0
  28. data/spec/dummy_app/Rakefile +5 -0
  29. data/spec/dummy_app/app/assets/config/manifest.js +1 -0
  30. data/spec/dummy_app/config/application.rb +31 -0
  31. data/spec/dummy_app/config/boot.rb +3 -0
  32. data/spec/dummy_app/config/environment.rb +5 -0
  33. data/spec/dummy_app/config/routes.rb +7 -0
  34. data/spec/integration/dashboard_controller_spec.rb +94 -0
  35. data/spec/integration/full_cache_flow_spec.rb +202 -0
  36. data/spec/integration/instrumentation_spec.rb +259 -0
  37. data/spec/integration/rack_after_reply_spec.rb +47 -0
  38. data/spec/integration/rake_tasks_spec.rb +17 -0
  39. data/spec/spec_helper.rb +64 -0
  40. data/spec/unit/cache_client_spec.rb +278 -0
  41. data/spec/unit/configuration_spec.rb +209 -0
  42. data/spec/unit/keyspace_spec.rb +93 -0
  43. data/spec/unit/stats_query_spec.rb +367 -0
  44. data/tasks/cache_stache.rake +74 -0
  45. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ CacheStache::Engine.routes.draw do
4
+ root to: "dashboard#index"
5
+ get "keyspaces/:name", to: "dashboard#keyspace", as: :keyspace
6
+ end
@@ -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