Skip to content

Commit 9db8ea4

Browse files
committed
perf(cache): Optimize cache by removing null attributes
1 parent 4086a3f commit 9db8ea4

File tree

6 files changed

+417
-39
lines changed

6 files changed

+417
-39
lines changed

app/services/subscriptions/charge_cache_middleware.rb

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,90 @@ 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) }
18-
.to_json
15+
fees = nil
16+
cached_fees = Subscriptions::ChargeCacheService.call(subscription:, charge:, charge_filter:, expires_in: cache_expiration) do
17+
fees = yield
18+
fees.map do |fee|
19+
fee_attributes = fee.attributes
20+
if (pricing_unit_usage = fee.pricing_unit_usage).present?
21+
pricing_unit_usage_attributes = compact_hash(pricing_unit_usage.attributes, COMPACTABLE_PRICING_UNIT_USAGE_ATTRIBUTES)
22+
fee_attributes["pricing_unit_usage"] = pricing_unit_usage_attributes
23+
end
24+
compact_fee(fee_attributes)
25+
end.to_json
1926
end
2027

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
28+
return fees if fees # avoid parsing the JSON if we already have the fees
2529

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

3333
private
3434

3535
attr_reader :subscription, :charge, :to_datetime, :cache
3636

37+
COMPACTABLE_PROPERTIES = [
38+
"fixed_charges_duration",
39+
"fixed_charges_from_datetime",
40+
"fixed_charges_to_datetime"
41+
]
42+
43+
COMPACTABLE_ATTRIBUTES = [
44+
"add_on_id",
45+
"applied_add_on_id",
46+
"charge_filter_id",
47+
"created_at",
48+
"deleted_at",
49+
"description",
50+
"failed_at",
51+
"fixed_charge_id",
52+
"group_id",
53+
"invoice_display_name",
54+
"invoice_id",
55+
"pay_in_advance_event_id",
56+
"pay_in_advance_event_transaction_id",
57+
"pay_in_advance",
58+
"pricing_unit_usage",
59+
"refunded_at",
60+
"succeeded_at",
61+
"true_up_parent_fee_id",
62+
"updated_at",
63+
"id"
64+
]
65+
66+
COMPACTABLE_PRICING_UNIT_USAGE_ATTRIBUTES = [
67+
"id",
68+
"fee_id",
69+
"created_at",
70+
"updated_at"
71+
]
72+
73+
def parse_cached_fees(cached_fees)
74+
JSON.parse(cached_fees).map do |fee_attributes|
75+
pricing_unit_usage = fee_attributes["pricing_unit_usage"]
76+
pricing_unit_usage = if pricing_unit_usage.present?
77+
PricingUnitUsage.new(pricing_unit_usage.slice(*PricingUnitUsage.column_names))
78+
end
79+
80+
Fee.new(**fee_attributes.slice(*Fee.column_names), pricing_unit_usage:)
81+
end
82+
end
83+
84+
# This method is used to compact the fee attributes to reduce the size of the cached fees. We explicitly define
85+
# which attributes are compactable and which are not to avoid losing information. For instance, the `grouped_by`
86+
# should keep the `nil` values.
87+
def compact_fee(fee_attributes)
88+
fee_attributes = compact_hash(fee_attributes, COMPACTABLE_ATTRIBUTES)
89+
if (properties = fee_attributes["properties"]).present?
90+
fee_attributes["properties"] = compact_hash(properties, COMPACTABLE_PROPERTIES)
91+
end
92+
fee_attributes
93+
end
94+
95+
def compact_hash(object, compactable_keys)
96+
object.reject { |key, value| compactable_keys.include?(key) && value.nil? }
97+
end
98+
3799
def cache_expiration
38100
[(to_datetime - Time.current).to_i.seconds, 0].max
39101
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?

spec/factories/charges.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
factory :standard_charge do
1111
charge_model { "standard" }
1212
properties do
13-
{amount: Faker::Number.between(from: 100, to: 500).to_s}
13+
{amount: "10"}
1414
end
1515
end
1616

spec/factories/fees.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
invoiceable_type { "Charge" }
4747
invoiceable_id { charge.id }
48+
events_count { 1 }
4849

4950
properties do
5051
{

spec/scenarios/customer_usage_spec.rb

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,126 @@
22

33
require "rails_helper"
44

5-
describe "Customer usage Scenario" do
5+
describe "Customer usage Scenario", cache: :redis do
66
let(:organization) { create(:organization, webhook_url: nil) }
77

88
let(:timezone) { "UTC" }
99
let(:customer) { create(:customer, organization:, timezone:, currency: "EUR") }
1010

11-
let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: false, interval: "yearly") }
11+
let(:plan) { create(:plan, organization:, amount_cents: 5000, pay_in_advance: false, interval: "yearly") }
12+
let(:billable_metric) { create(:billable_metric, organization:, code: "image_generation", name: "Image generation") }
13+
let(:charge) { create(:standard_charge, plan:, billable_metric:, invoice_display_name: "Image generation") }
14+
15+
before { charge }
16+
17+
def customer_usage_json(units: 1.0, from_datetime: "2043-01-01T09:30:00Z", to_datetime: "2043-12-31T23:59:59Z")
18+
total_amount_cents = 1000 * units
19+
{
20+
customer_usage: {
21+
amount_cents: total_amount_cents,
22+
charges_usage: [
23+
{
24+
amount_cents: total_amount_cents,
25+
amount_currency: "EUR",
26+
billable_metric: {
27+
aggregation_type: "count_agg",
28+
code: "image_generation",
29+
lago_id: billable_metric.id,
30+
name: billable_metric.name
31+
},
32+
charge: {
33+
charge_model: "standard",
34+
invoice_display_name: "Image generation",
35+
lago_id: charge.id
36+
},
37+
events_count: units.to_i,
38+
filters: [],
39+
grouped_usage: [],
40+
pricing_unit_details: nil,
41+
total_aggregated_units: units.to_d.to_s,
42+
units: units.to_d.to_s
43+
}
44+
],
45+
currency: "EUR",
46+
from_datetime: from_datetime,
47+
issuing_date: to_datetime.slice(0, 10),
48+
lago_invoice_id: nil,
49+
taxes_amount_cents: 0,
50+
to_datetime: to_datetime,
51+
total_amount_cents: total_amount_cents
52+
}
53+
}
54+
end
55+
56+
def fetch_and_assert_current_usage(units: 1.0, from_datetime: "2043-01-01T09:30:00Z", to_datetime: "2043-12-31T23:59:59Z")
57+
subscription = customer.subscriptions.first
58+
fetch_current_usage(customer:, subscription:)
59+
60+
expect(json).to eq(customer_usage_json(units: units, from_datetime: from_datetime, to_datetime: to_datetime))
61+
end
1262

1363
context "with start date in the past" do
1464
it "retrieve the customer usage" do
15-
travel_to(DateTime.new(2023, 8, 8, 9, 30)) do
16-
create_subscription(
65+
travel_to(DateTime.new(2043, 8, 8, 9, 30)) do
66+
subscription = create_subscription(
1767
{
1868
external_customer_id: customer.external_id,
1969
external_id: customer.external_id,
2070
plan_code: plan.code,
21-
subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601
22-
}
71+
subscription_at: DateTime.new(2043, 1, 1, 9, 30).iso8601
72+
},
73+
as: :model
2374
)
2475

25-
subscription = customer.subscriptions.first
26-
fetch_current_usage(customer:, subscription:)
76+
fetch_and_assert_current_usage(units: 0)
77+
78+
create_event({
79+
external_subscription_id: subscription.external_id,
80+
timestamp: Time.now.to_f,
81+
code: "image_generation",
82+
properties: {}
83+
})
2784

28-
expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z")
29-
expect(json[:customer_usage][:to_datetime]).to eq("2023-12-31T23:59:59Z")
85+
fetch_and_assert_current_usage(units: 1)
86+
87+
# test cache
88+
fetch_and_assert_current_usage(units: 1)
89+
90+
create_event({
91+
external_subscription_id: subscription.external_id,
92+
timestamp: Time.now.to_f,
93+
code: "image_generation",
94+
properties: {}
95+
})
96+
97+
fetch_and_assert_current_usage(units: 2)
98+
99+
Event.last.destroy
100+
101+
# test cache
102+
fetch_and_assert_current_usage(units: 2)
103+
104+
Rails.cache.clear
105+
106+
fetch_and_assert_current_usage(units: 1)
30107
end
31108
end
32109

33110
context "with Europe/Berlin timezone" do
34111
let(:timezone) { "Europe/Berlin" }
35112

36113
it "retrieve the customer usage" do
37-
travel_to(DateTime.new(2023, 8, 8, 9, 30)) do
114+
travel_to(DateTime.new(2043, 8, 8, 9, 30)) do
38115
create_subscription(
39116
{
40117
external_customer_id: customer.external_id,
41118
external_id: customer.external_id,
42119
plan_code: plan.code,
43-
subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601
120+
subscription_at: DateTime.new(2043, 1, 1, 9, 30).iso8601
44121
}
45122
)
46123

47-
subscription = customer.subscriptions.first
48-
fetch_current_usage(customer:, subscription:)
49-
50-
expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z")
51-
expect(json[:customer_usage][:to_datetime]).to eq("2023-12-31T22:59:59Z")
124+
fetch_and_assert_current_usage(units: 0, from_datetime: "2043-01-01T09:30:00Z", to_datetime: "2043-12-31T22:59:59Z")
52125
end
53126
end
54127
end
@@ -57,21 +130,17 @@
57130
let(:timezone) { "America/Los_Angeles" }
58131

59132
it "retrieve the customer usage" do
60-
travel_to(DateTime.new(2023, 8, 8, 9, 30)) do
133+
travel_to(DateTime.new(2043, 8, 8, 9, 30)) do
61134
create_subscription(
62135
{
63136
external_customer_id: customer.external_id,
64137
external_id: customer.external_id,
65138
plan_code: plan.code,
66-
subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601
139+
subscription_at: DateTime.new(2043, 1, 1, 9, 30).iso8601
67140
}
68141
)
69142

70-
subscription = customer.subscriptions.first
71-
fetch_current_usage(customer:, subscription:)
72-
73-
expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z")
74-
expect(json[:customer_usage][:to_datetime]).to eq("2024-01-01T07:59:59Z")
143+
fetch_and_assert_current_usage(units: 0, from_datetime: "2043-01-01T09:30:00Z", to_datetime: "2044-01-01T07:59:59Z")
75144
end
76145
end
77146
end

0 commit comments

Comments
 (0)