From 16794d0a2404d67c4d9e9dc3e791750b6d2cf566 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 20 Jul 2026 17:01:50 +0200 Subject: [PATCH] misc(cache): Stop expiring charge cache ## Context The charge usage cache used to be expired eagerly, from the event post-processing path, on every event reception. Lazy validation, gated behind the `lazy_charge_usage_cache` feature flag, replaced it: the cache entry carries the watermark of what it aggregated and is rejected at read time when a newer event exists. ## Description Remove the eager expiration and stop reading the feature flag that selected between the two strategies, so lazy validation is the only behaviour. The cache key version is now `2` for every organization, which drops the entries written under the legacy shape instead of reading them back with the wrong value shape. Full usage entries and the ingestion timestamps that feed them are no longer conditioned on the flag either: they now depend only on the granular lifetime usage integration and on the request being describable by the cache key. The flag itself is kept declared for now, and will be retired in a follow-up once no organization carries it any more. Co-Authored-By: Claude Opus 5 (1M context) --- app/services/events/post_process_service.rb | 11 ---- .../invoices/customer_usage_service.rb | 4 +- .../subscriptions/charge_cache_service.rb | 22 ++----- .../events/post_process_service_spec.rb | 38 +----------- .../invoices/customer_usage_service_spec.rb | 61 ++++--------------- .../charge_cache_middleware_spec.rb | 1 - .../charge_cache_service_spec.rb | 9 --- 7 files changed, 22 insertions(+), 124 deletions(-) diff --git a/app/services/events/post_process_service.rb b/app/services/events/post_process_service.rb index cf3645adcb08..f387c751f492 100644 --- a/app/services/events/post_process_service.rb +++ b/app/services/events/post_process_service.rb @@ -11,7 +11,6 @@ def initialize(event:) end def call - expire_cached_charges create_enriched_events track_subscription_activity customer&.flag_wallets_for_refresh @@ -81,16 +80,6 @@ def billable_metric @billable_metric ||= organization.billable_metrics.find_by(code: event.code) end - def expire_cached_charges - return if organization.feature_flag_enabled?(:lazy_charge_usage_cache) - return if active_subscription.nil? - return unless billable_metric - - charges_and_filters.each do |charge, filter| - Subscriptions::ChargeCacheService.expire_cache(subscription: active_subscription, charge:, charge_filter: filter) - end - end - def create_enriched_events return unless organization.feature_flag_enabled?(:postgres_enriched_events) return if active_subscription.nil? diff --git a/app/services/invoices/customer_usage_service.rb b/app/services/invoices/customer_usage_service.rb index 41ecd9e317cb..b05d1d16a681 100644 --- a/app/services/invoices/customer_usage_service.rb +++ b/app/services/invoices/customer_usage_service.rb @@ -287,10 +287,10 @@ def charge_cache_enabled? (!usage_filters.full_usage || full_usage_cache_enabled?) end - # Full usage is cached only with lazy validation, the one invalidation that clears its key. + # skip_grouping and filter_by_presentation change the fees but are absent from the cache key, so + # a full usage entry is only written when neither of them narrows the request. def full_usage_cache_enabled? organization.granular_lifetime_usage_enabled? && - organization.feature_flag_enabled?(:lazy_charge_usage_cache) && !usage_filters.skip_grouping && usage_filters.filter_by_presentation.nil? end diff --git a/app/services/subscriptions/charge_cache_service.rb b/app/services/subscriptions/charge_cache_service.rb index dacbdf33c2a6..f46e1389aec3 100644 --- a/app/services/subscriptions/charge_cache_service.rb +++ b/app/services/subscriptions/charge_cache_service.rb @@ -2,11 +2,9 @@ module Subscriptions class ChargeCacheService < CacheService - CACHE_KEY_VERSION = "1" - # Lazy validation stores a different value shape (wrapped with its creation time), so it uses - # its own version. Enabling the feature flag gradually migrates an organization's entries to - # this version instead of invalidating every organization's cache at once. - LAZY_CACHE_KEY_VERSION = "2" + # Bumped to "2" when lazy validation was introduced: it stores a different value shape (wrapped + # with its creation time) than the legacy eager-invalidation entries. + CACHE_KEY_VERSION = "2" # Full usage aggregates from subscription.started_at, not the current period start. The two # windows can coincide, but started_at is editable, so they never share an entry. @@ -57,7 +55,7 @@ def initialize(subscription:, charge:, charge_filter: nil, full_usage: false, ex def cache_key [ "charge-usage", - cache_key_version, + CACHE_KEY_VERSION, charge.id, subscription.id, charge.updated_at.iso8601, @@ -71,18 +69,8 @@ def cache_key attr_reader :subscription, :charge, :charge_filter, :full_usage - def cache_key_version - lazy_validation? ? LAZY_CACHE_KEY_VERSION : CACHE_KEY_VERSION - end - def track_created_at? - lazy_validation? - end - - def lazy_validation? - return @lazy_validation if defined?(@lazy_validation) - - @lazy_validation = subscription.organization.feature_flag_enabled?(:lazy_charge_usage_cache) + true end end end diff --git a/spec/services/events/post_process_service_spec.rb b/spec/services/events/post_process_service_spec.rb index 4f895ff9f593..887fa17c7737 100644 --- a/spec/services/events/post_process_service_spec.rb +++ b/spec/services/events/post_process_service_spec.rb @@ -49,29 +49,6 @@ .with(subscription:, organization:, date: expected_date) end - describe "charge usage cache expiration" do - it "expires the charge usage cache for the matching charges" do - allow(Subscriptions::ChargeCacheService).to receive(:expire_cache) - - process_service.call - - expect(Subscriptions::ChargeCacheService).to have_received(:expire_cache) - .with(subscription:, charge:, charge_filter: nil) - end - - context "when the lazy charge usage cache flag is enabled" do - let(:organization) { create(:organization, feature_flags: [:lazy_charge_usage_cache]) } - - it "does not expire the cache, letting the read-time validation handle it" do - allow(Subscriptions::ChargeCacheService).to receive(:expire_cache) - - process_service.call - - expect(Subscriptions::ChargeCacheService).not_to have_received(:expire_cache) - end - end - end - context "with events enrichment" do it "does not create an enriched event" do expect { process_service.call }.not_to change(EnrichedEvent, :count) @@ -95,15 +72,6 @@ end let(:event_properties) { {"item_id" => "12"} } - it "falls back on the currently active subscription to expire the charge usage cache" do - allow(Subscriptions::ChargeCacheService).to receive(:expire_cache) - - process_service.call - - expect(Subscriptions::ChargeCacheService).to have_received(:expire_cache) - .with(subscription:, charge:, charge_filter: nil) - end - it "tracks subscription activity on the fallback subscription" do allow(UsageMonitoring::TrackSubscriptionActivityService).to receive(:call) @@ -119,12 +87,12 @@ end context "with a non-recurring billable metric" do - it "does not fall back and skips the charge usage cache expiration" do - allow(Subscriptions::ChargeCacheService).to receive(:expire_cache) + it "does not fall back on the currently active subscription" do + allow(UsageMonitoring::TrackSubscriptionActivityService).to receive(:call) process_service.call - expect(Subscriptions::ChargeCacheService).not_to have_received(:expire_cache) + expect(UsageMonitoring::TrackSubscriptionActivityService).not_to have_received(:call) end end end diff --git a/spec/services/invoices/customer_usage_service_spec.rb b/spec/services/invoices/customer_usage_service_spec.rb index 729aabf41551..451c5d1e18fe 100644 --- a/spec/services/invoices/customer_usage_service_spec.rb +++ b/spec/services/invoices/customer_usage_service_spec.rb @@ -44,6 +44,8 @@ ) end + # created_at predates the aggregation: CacheService refuses to store a value whose watermark is + # younger than SETTLE_WINDOW, so freshly ingested events would never populate the charge cache. let(:events) do create_list( :event, @@ -52,7 +54,8 @@ subscription:, customer:, code: billable_metric.code, - timestamp: + timestamp:, + created_at: 1.hour.ago ) end @@ -540,7 +543,6 @@ end context "when granular_lifetime_usage is enabled", :premium do - # Both keys are built through the service so their version follows the lazy validation flag. let(:current_usage_cache_key) do Subscriptions::ChargeCacheService.new(subscription:, charge:).cache_key end @@ -549,10 +551,7 @@ Subscriptions::ChargeCacheService.new(subscription:, charge:, full_usage: true).cache_key end - before do - organization.update!(premium_integrations: %w[granular_lifetime_usage]) - organization.enable_feature_flag!(:lazy_charge_usage_cache) - end + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } context "when filter_by_charge_id is provided and no prorated charges" do subject(:usage_service) do @@ -750,18 +749,6 @@ expect(Rails.cache.exist?(current_usage_cache_key)).to be(false) end end - - context "when the organization does not lazily validate the cache" do - before { organization.disable_feature_flag!(:lazy_charge_usage_cache) } - - it "does not cache the charge at all" do - travel_to(current_date) do - expect { usage_service.call }.not_to change { Rails.cache.exist?(full_usage_cache_key) }.from(false) - - expect(Rails.cache.exist?(current_usage_cache_key)).to be(false) - end - end - end end end end @@ -899,7 +886,6 @@ end end - # Full usage is cached only where lazy validation can reject a stale entry. context "when the full usage is queried outside of the first billing period", :premium do subject(:usage_service) do described_class.new( @@ -921,31 +907,13 @@ before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } - it "skips both the cache and the ingestion timestamps" do - expect { usage_service.call }.not_to change { Rails.cache.exist?(full_usage_cache_key) }.from(false) + it "caches the charge under the full usage key and requests the ingestion timestamps" do + expect { usage_service.call } + .to change { Rails.cache.exist?(full_usage_cache_key) }.from(false).to(true) + expect(Rails.cache.exist?(current_usage_cache_key)).to be(false) expect(Events::BillingPeriodFilterService).to have_received(:for_charges!) - .with(hash_including(with_last_seen_at: false)) - end - - context "when the organization lazily validates the cache" do - # created_at predates the aggregation: CacheService refuses to store a value whose - # watermark is younger than SETTLE_WINDOW. - let(:events) do - create_list(:event, 2, organization:, subscription:, customer:, - code: billable_metric.code, timestamp:, created_at: 1.hour.ago) - end - - before { organization.enable_feature_flag!(:lazy_charge_usage_cache) } - - it "caches the charge under the full usage key and requests the ingestion timestamps" do - expect { usage_service.call } - .to change { Rails.cache.exist?(full_usage_cache_key) }.from(false).to(true) - - expect(Rails.cache.exist?(current_usage_cache_key)).to be(false) - expect(Events::BillingPeriodFilterService).to have_received(:for_charges!) - .with(hash_including(with_last_seen_at: true)) - end + .with(hash_including(with_last_seen_at: true)) end end @@ -961,8 +929,6 @@ ) end - before { organization.enable_feature_flag!(:lazy_charge_usage_cache) } - it "refuses the request and caches nothing" do result = usage_service.call @@ -993,11 +959,8 @@ Subscriptions::ChargeCacheService.new(subscription:, charge:).cache_key end - # Lazy validation is on, so the refusal can only come from the filter shape. - before do - organization.update!(premium_integrations: %w[granular_lifetime_usage]) - organization.enable_feature_flag!(:lazy_charge_usage_cache) - end + # granular_lifetime_usage is on, so the refusal can only come from the filter shape. + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } it "skips both the cache and the ingestion timestamps" do expect { usage_service.call }.not_to change { Rails.cache.exist?(full_usage_cache_key) }.from(false) diff --git a/spec/services/subscriptions/charge_cache_middleware_spec.rb b/spec/services/subscriptions/charge_cache_middleware_spec.rb index d39ab26e580c..13d30dac5690 100644 --- a/spec/services/subscriptions/charge_cache_middleware_spec.rb +++ b/spec/services/subscriptions/charge_cache_middleware_spec.rb @@ -19,7 +19,6 @@ end before do - subscription.organization.enable_feature_flag!(:lazy_charge_usage_cache) Rails.cache.clear end diff --git a/spec/services/subscriptions/charge_cache_service_spec.rb b/spec/services/subscriptions/charge_cache_service_spec.rb index e5ec6c1fbe45..e27d3a5035b4 100644 --- a/spec/services/subscriptions/charge_cache_service_spec.rb +++ b/spec/services/subscriptions/charge_cache_service_spec.rb @@ -24,15 +24,6 @@ end end - context "when the lazy charge usage cache flag is enabled" do - before { subscription.organization.enable_feature_flag!(:lazy_charge_usage_cache) } - - it "uses the lazy cache key version" do - expect(cache_service.cache_key) - .to eq("charge-usage/#{described_class::LAZY_CACHE_KEY_VERSION}/#{charge.id}/#{subscription.id}/#{charge.updated_at.iso8601}") - end - end - context "with full usage" do subject(:cache_service) { described_class.new(subscription:, charge:, charge_filter:, full_usage: true) }