Skip to content

Commit 563779b

Browse files
committed
perf(cache): Optimize cache by removing null attributes
1 parent a2b7c0d commit 563779b

File tree

3 files changed

+247
-13
lines changed

3 files changed

+247
-13
lines changed

app/services/subscriptions/charge_cache_middleware.rb

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,44 @@ def initialize(subscription:, charge:, to_datetime:, cache: true)
1212
def call(charge_filter:)
1313
return yield unless cache
1414

15-
json = Subscriptions::ChargeCacheService.call(subscription:, charge:, charge_filter:, expires_in: cache_expiration) do
16-
yield
17-
.map { |fee| fee.attributes.merge("pricing_unit_usage" => fee.pricing_unit_usage&.attributes) }
15+
fees = nil
16+
cached_fees = Subscriptions::ChargeCacheService.call(subscription:, charge:, charge_filter:, expires_in: cache_expiration) do
17+
fees = yield
18+
fees.map { |fee| fee.attributes.merge("pricing_unit_usage" => fee.pricing_unit_usage&.attributes) }
19+
.then { |fees| deep_compact(fees) }
1820
.to_json
1921
end
2022

21-
JSON.parse(json).map do |j|
22-
pricing_unit_usage = if j["pricing_unit_usage"].present?
23-
PricingUnitUsage.new(j["pricing_unit_usage"].slice(*PricingUnitUsage.column_names))
24-
end
23+
return fees if fees # avoid parsing the JSON if we already have the fees
2524

26-
Fee.new(
27-
**j.slice(*Fee.column_names),
28-
pricing_unit_usage:
29-
)
30-
end
25+
parse_cached_fees(cached_fees)
3126
end
3227

3328
private
3429

3530
attr_reader :subscription, :charge, :to_datetime, :cache
3631

32+
def parse_cached_fees(cached_fees)
33+
JSON.parse(cached_fees).map do |fee_attributes|
34+
pricing_unit_usage = fee_attributes["pricing_unit_usage"]
35+
pricing_unit_usage = if pricing_unit_usage.present?
36+
PricingUnitUsage.new(pricing_unit_usage.slice(*PricingUnitUsage.column_names))
37+
end
38+
39+
Fee.new(**fee_attributes.slice(*Fee.column_names), pricing_unit_usage:)
40+
end
41+
end
42+
43+
def deep_compact(object)
44+
if object.is_a?(Hash)
45+
object.compact.transform_values { |v| deep_compact(v) }
46+
elsif object.is_a?(Array)
47+
object.map { |v| deep_compact(v) }
48+
else
49+
object
50+
end
51+
end
52+
3753
def cache_expiration
3854
[(to_datetime - Time.current).to_i.seconds, 0].max
3955
end

config/environments/production.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@
6161
Rails.logger.error(exception.backtrace.join("\n"))
6262

6363
Sentry.capture_exception(exception)
64-
}
64+
},
65+
compress_threshold: 512 # 512 bytes
6566
}
6667

6768
if ENV["LAGO_REDIS_CACHE_PASSWORD"].present? && !ENV["LAGO_REDIS_CACHE_PASSWORD"].empty?
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Subscriptions::ChargeCacheMiddleware, cache: :redis do
6+
subject(:middleware) { described_class.new(subscription:, charge:, to_datetime:, cache:) }
7+
8+
let(:organization) { create(:organization, premium_integrations: zero_amount_fees_enabled ? ["zero_amount_fees"] : []) }
9+
let(:billing_entity) { organization.billing_entities.first }
10+
let(:zero_amount_fees_enabled) { false }
11+
let(:plan) { create(:plan, organization:) }
12+
let(:subscription) { create(:subscription, plan:, organization:) }
13+
let(:charge) { create(:standard_charge, plan: plan, organization:) }
14+
let(:pricing_unit) { create(:pricing_unit, organization:, short_name: "CAR") }
15+
let(:to_datetime) { Time.current + 1.hour }
16+
let(:cache) { true }
17+
let(:charge_filter) { nil }
18+
let(:cache_key) do
19+
[
20+
"charge-usage",
21+
1,
22+
charge.id,
23+
subscription.id,
24+
charge.updated_at.iso8601,
25+
charge_filter&.id,
26+
charge_filter&.updated_at&.iso8601
27+
].compact.join("/")
28+
end
29+
30+
def fee(amount_cents:, with_pricing_unit_usage: false)
31+
Fee.new(
32+
amount_cents:,
33+
amount_currency: "USD",
34+
fee_type: "charge",
35+
pricing_unit_usage: with_pricing_unit_usage ? PricingUnitUsage.new(
36+
organization: organization,
37+
pricing_unit:,
38+
short_name: "CAR",
39+
conversion_rate: 1.5,
40+
amount_cents: 150,
41+
precise_amount_cents: 150.0,
42+
unit_amount_cents: 10,
43+
precise_unit_amount: 10.0
44+
) : nil,
45+
charge: charge,
46+
charge_filter: charge_filter,
47+
organization: organization,
48+
billing_entity: billing_entity,
49+
subscription: subscription
50+
)
51+
end
52+
53+
def cached_fee_payload(amount_cents:, overrides: {})
54+
{
55+
"amount_cents" => amount_cents,
56+
"amount_currency" => "USD",
57+
"amount_details" => {},
58+
"fee_type" => "charge",
59+
"charge_id" => charge.id,
60+
"organization_id" => organization.id,
61+
"subscription_id" => subscription.id,
62+
"billing_entity_id" => billing_entity.id,
63+
"grouped_by" => {},
64+
"payment_status" => "pending",
65+
"precise_amount_cents" => "0.0",
66+
"precise_coupons_amount_cents" => "0.0",
67+
"precise_credit_notes_amount_cents" => "0.0",
68+
"precise_unit_amount" => "0.0",
69+
"pay_in_advance" => false,
70+
"properties" => {},
71+
"taxes_base_rate" => 1.0,
72+
"taxes_precise_amount_cents" => "0.0",
73+
"taxes_rate" => 0.0,
74+
"unit_amount_cents" => 0,
75+
"units" => "0.0",
76+
**(charge_filter ? {"charge_filter_id" => charge_filter.id} : {})
77+
}.merge(overrides)
78+
end
79+
80+
def charge_cache_key
81+
"charge-usage/1/#{charge.id}/#{subscription.id}/#{charge.updated_at.iso8601}"
82+
end
83+
84+
def charge_filter_cache_key
85+
"charge-usage/1/#{charge.id}/#{subscription.id}/#{charge.updated_at.iso8601}/#{charge_filter.id}/#{charge_filter.updated_at.iso8601}"
86+
end
87+
88+
def fetch_cache(key)
89+
value = Rails.cache.read(key)
90+
return nil if value.nil?
91+
92+
JSON.parse(value)
93+
end
94+
95+
def expire_time(key)
96+
Rails.cache.redis.with { |r| r.pexpiretime(key).to_f / 1000 }
97+
end
98+
99+
# We have to compare attributes as ActiveRecord compares objects by their id
100+
def expect_to_match_fees(actual, expected)
101+
expect(actual).to all(be_a(Fee))
102+
expect(expected).to all(be_a(Fee))
103+
104+
expect(actual.map(&:attributes)).to eq(expected.map(&:attributes))
105+
end
106+
107+
describe "#call" do
108+
let(:fees) { [fee(amount_cents: 100), fee(amount_cents: 200)] }
109+
let(:other_fees) { [fee(amount_cents: 300)] }
110+
111+
context "when cache is disabled" do
112+
let(:cache) { false }
113+
114+
it "yields and returns the block result without caching" do
115+
result = middleware.call(charge_filter:) { fees }
116+
expect_to_match_fees(result, fees)
117+
118+
result = middleware.call(charge_filter:) { other_fees }
119+
expect_to_match_fees(result, other_fees)
120+
121+
expect(fetch_cache(charge_cache_key)).to be_nil
122+
end
123+
end
124+
125+
context "when cache is enabled" do
126+
it "caches and returns the fees" do
127+
result = middleware.call(charge_filter:) { fees }
128+
129+
expect_to_match_fees(result, fees)
130+
131+
expect(fetch_cache(charge_cache_key)).to eq([
132+
cached_fee_payload(amount_cents: 100),
133+
cached_fee_payload(amount_cents: 200)
134+
])
135+
key_expire_time = expire_time(charge_cache_key)
136+
expect(key_expire_time).to be_within(10.seconds).of(to_datetime.to_i)
137+
138+
result = middleware.call(charge_filter:) { other_fees }
139+
expect_to_match_fees(result, fees)
140+
141+
expect(fetch_cache(charge_cache_key)).to eq([
142+
cached_fee_payload(amount_cents: 100),
143+
cached_fee_payload(amount_cents: 200)
144+
])
145+
# Key expire time should not be updated
146+
expect(expire_time(charge_cache_key)).to eq(key_expire_time)
147+
end
148+
149+
context "with a charge filter" do
150+
let(:charge_filter) { create(:charge_filter) }
151+
152+
it "passes the charge filter to the cache service" do
153+
result = middleware.call(charge_filter:) { fees }
154+
155+
expect_to_match_fees(result, fees)
156+
157+
expect(fetch_cache(charge_filter_cache_key)).to eq([
158+
cached_fee_payload(amount_cents: 100),
159+
cached_fee_payload(amount_cents: 200)
160+
])
161+
162+
result = middleware.call(charge_filter:) { other_fees }
163+
expect_to_match_fees(result, fees)
164+
165+
expect(fetch_cache(charge_filter_cache_key)).to eq([
166+
cached_fee_payload(amount_cents: 100),
167+
cached_fee_payload(amount_cents: 200)
168+
])
169+
end
170+
end
171+
172+
context "with pricing unit usage" do
173+
let(:fees) { [fee(amount_cents: 100, with_pricing_unit_usage: true)] }
174+
175+
it "caches and reconstructs fees with pricing unit usage" do
176+
result = middleware.call(charge_filter:) { fees }
177+
expect_to_match_fees(result, fees)
178+
179+
expect(fetch_cache(charge_cache_key)).to eq([
180+
cached_fee_payload(
181+
amount_cents: 100,
182+
overrides: {"pricing_unit_usage" => {
183+
"amount_cents" => 150,
184+
"conversion_rate" => "1.5",
185+
"organization_id" => organization.id,
186+
"precise_amount_cents" => "150.0",
187+
"precise_unit_amount" => "10.0",
188+
"pricing_unit_id" => pricing_unit.id,
189+
"short_name" => "CAR",
190+
"unit_amount_cents" => 10
191+
}}
192+
)
193+
])
194+
195+
result = middleware.call(charge_filter:) { other_fees }
196+
expect_to_match_fees(result, fees)
197+
198+
expect(fetch_cache(charge_cache_key)).to eq([
199+
cached_fee_payload(
200+
amount_cents: 100,
201+
overrides: {"pricing_unit_usage" => {
202+
"amount_cents" => 150,
203+
"conversion_rate" => "1.5",
204+
"organization_id" => organization.id,
205+
"precise_amount_cents" => "150.0",
206+
"precise_unit_amount" => "10.0",
207+
"pricing_unit_id" => pricing_unit.id,
208+
"short_name" => "CAR",
209+
"unit_amount_cents" => 10
210+
}}
211+
)
212+
])
213+
end
214+
end
215+
end
216+
end
217+
end

0 commit comments

Comments
 (0)