From 888b0d1db99bfaed24e40574a95d8d6af10e523f Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 20 Sep 2026 21:38:42 +1200 Subject: [PATCH 1/2] Make memcached opt-in, default to a capped in-process cache The free Memcachier dev plan is timing out on every call in production (58 IO::TimeoutError in a 14 minute log window). Each request pays the 0.5s socket timeout and then renders uncached anyway, so the cache is currently pure overhead on a dyno that is already being OOM-killed. Memcached is now used only when USE_MEMCACHED=true and MEMCACHIER_SERVERS is present. Otherwise production uses a memory store capped at 32MB via RAILS_CACHE_SIZE_MB, so deploying restores real caching without the timeouts and without an addon upgrade. The memory store is per process, so homepage_stats gets an expires_in to bound staleness. It is the only fragment that relies on expire_fragment rather than a versioned cache key. Sessions are cookie-based and every other cached value is either keyed by cache_key_with_version or carries a TTL, so nothing depends on the cache being shared between processes. Co-Authored-By: Claude Opus 5 --- app/views/home/_stats.html.haml | 4 +++- config/environments/production.rb | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app/views/home/_stats.html.haml b/app/views/home/_stats.html.haml index 7ad0df6ae..34d2ba0d9 100644 --- a/app/views/home/_stats.html.haml +++ b/app/views/home/_stats.html.haml @@ -1,4 +1,6 @@ -- cache("homepage_stats") do +-# expires_in bounds staleness: expire_fragment only clears the cache of the +-# process that handled the write, and the cache store may be per process. +- cache("homepage_stats", expires_in: 1.hour) do %p.stats = t('.message_html', member: link_to(t('.member_linktext', count: Member.confirmed.size.to_i), members_path), diff --git a/config/environments/production.rb b/config/environments/production.rb index 9bfa08117..4caba1b9e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -51,15 +51,22 @@ Rails.application.configure do # Prepend all log lines with the following tags. config.log_tags = [:request_id] - # Use a different cache store in production. - config.cache_store = :mem_cache_store, - (ENV["MEMCACHIER_SERVERS"] || "").split(","), - { username: ENV.fetch("MEMCACHIER_USERNAME", nil), - password: ENV.fetch("MEMCACHIER_PASSWORD", nil), - failover: true, - socket_timeout: 0.5, - socket_failure_delay: 0.2, - down_retry_delay: 60 } + # Memcached is opt-in via USE_MEMCACHED. Otherwise each process keeps its own + # capped in-memory cache: the store is per Puma worker, so the dyno pays + # RAILS_CACHE_SIZE_MB once per worker, not once in total. + memcached_servers = ENV.fetch("MEMCACHIER_SERVERS", "").split(",") + + config.cache_store = if ENV["USE_MEMCACHED"] == "true" && memcached_servers.any? + [:mem_cache_store, memcached_servers, + { username: ENV.fetch("MEMCACHIER_USERNAME", nil), + password: ENV.fetch("MEMCACHIER_PASSWORD", nil), + failover: true, + socket_timeout: 0.5, + socket_failure_delay: 0.2, + down_retry_delay: 60 }] + else + [:memory_store, { size: Integer(ENV.fetch("RAILS_CACHE_SIZE_MB", "32")).megabytes }] + end # Use a real queuing backend for Active Job (and separate queues per environment) # config.active_job.queue_adapter = :resque From 17c3491202735c956b225925632ba950e2022fd1 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 21 Sep 2026 12:30:56 +0930 Subject: [PATCH 2/2] Update app/views/home/_stats.html.haml --- app/views/home/_stats.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/home/_stats.html.haml b/app/views/home/_stats.html.haml index 34d2ba0d9..949a98008 100644 --- a/app/views/home/_stats.html.haml +++ b/app/views/home/_stats.html.haml @@ -1,6 +1,6 @@ -# expires_in bounds staleness: expire_fragment only clears the cache of the -# process that handled the write, and the cache store may be per process. -- cache("homepage_stats", expires_in: 1.hour) do +- cache("homepage_stats", expires_in: 12.hours) do %p.stats = t('.message_html', member: link_to(t('.member_linktext', count: Member.confirmed.size.to_i), members_path),