Skip to content

Commit 3f2633a

Browse files
tests billing engine v2
1 parent 6aff023 commit 3f2633a

7 files changed

Lines changed: 57 additions & 36 deletions

File tree

app/jobs/billing_cycles/finalize_invoice_job.rb

Lines changed: 0 additions & 28 deletions
This file was deleted.

app/jobs/billing_cycles/process_job.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ class ProcessJob < ApplicationJob
77
# lock_ttl auto-expires the lock so a crashed job never blocks that customer forever.
88
unique :until_executed, on_conflict: :log, lock_ttl: 12.hours
99

10+
# Invoice finalization (numbering) happens inline in ProcessService and serialises per
11+
# billing_entity; under contention it raises SequenceError, which we retry (mirrors the
12+
# legacy BillSubscriptionJob). ProcessService's reconcile makes the retry idempotent.
13+
retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay
14+
retry_on Sequenced::SequenceError, wait: :polynomially_longer, attempts: 15, jitter: 0.75
15+
1016
queue_as do
1117
if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"])
1218
:billing

app/jobs/clock/create_billing_cycles_job.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ module Clock
77
# page. The chain spreads the scan across small runs; the per-customer jobs run in
88
# parallel. Deduped per customer downstream, so overlapping ticks don't double-schedule.
99
class CreateBillingCyclesJob < ClockJob
10-
unique :until_executed, on_conflict: :log
10+
# Self-chaining, so a crashed head could otherwise hold the uniqueness lock and stall
11+
# the outbox forever; lock_ttl auto-expires it well within a few 5-minute ticks.
12+
unique :until_executed, on_conflict: :log, lock_ttl: 10.minutes
1113

1214
BATCH_SIZE = 1_000
1315

app/jobs/clock/process_billing_cycles_job.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ module Clock
77
# pending set into invoices. Decoupled from production; a failed cycle stays pending and
88
# is retried on the next scan.
99
class ProcessBillingCyclesJob < ClockJob
10-
unique :until_executed, on_conflict: :log
10+
# Self-chaining, so a crashed head could otherwise hold the uniqueness lock and stall
11+
# the outbox forever; lock_ttl auto-expires it well within a few 5-minute ticks.
12+
unique :until_executed, on_conflict: :log, lock_ttl: 10.minutes
1113

1214
BATCH_SIZE = 1_000
1315

app/services/billing_cycles/process_service.rb

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ def call
2727
pending_cycles.group_by { |cycle| invoice_key(cycle) }.each_value do |cycles|
2828
result.invoices << build_invoice(cycles)
2929
end
30+
31+
# Finalize inline (invoice numbering) in the same job — one job per invoice, like
32+
# the legacy BillSubscriptionJob, so no extra Sidekiq hop. Each finalize runs in
33+
# its OWN short transaction (invoice.finalized!), so the per-billing_entity
34+
# numbering lock is held briefly and a race raises a clean SequenceError that the
35+
# ProcessJob retries. Retry-safe: a failed finalize leaves the invoice `generating`,
36+
# and this reconcile re-finalizes it on the retry (it re-queries generating
37+
# invoices, so it doesn't re-create — the cycles are already `done`).
38+
finalize_generating_invoices
3039
end
3140

3241
result
@@ -108,13 +117,24 @@ def build_invoice(cycles)
108117
cycles.each { |cycle| cycle.update!(status: :done, invoice:) }
109118
end
110119

111-
# Finalize (invoice numbering) in a separate, retry-protected job. Numbering
112-
# serialises per billing_entity on a 10s advisory lock, so keeping it OUT of this
113-
# parallel per-customer transaction means the contention can't crash the fan-out —
114-
# a numbering race just retries the finalize on the already-persisted invoice.
115-
BillingCycles::FinalizeInvoiceJob.perform_later(invoice)
116-
117120
invoice
118121
end
122+
123+
# Finalize every still-generating invoice this customer's cycles produced — this run's
124+
# plus any left generating by a failed finalize on a previous attempt. Re-querying
125+
# (not the built array) is what makes a job retry recover orphans without re-creating.
126+
def finalize_generating_invoices
127+
invoice_ids = BillingCycle
128+
.where(customer_id: customer.id, status: :done)
129+
.where.not(invoice_id: nil)
130+
.joins(:invoice)
131+
.where(invoices: {status: :generating})
132+
.distinct
133+
.pluck(:invoice_id)
134+
135+
Invoice.where(id: invoice_ids).find_each do |invoice|
136+
Invoices::TransitionToFinalStatusService.call(invoice:)
137+
end
138+
end
119139
end
120140
end

app/services/v2/subscriptions/terminate_service.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def call
3434
subscription.mark_as_terminated!(terminated_at)
3535
end
3636

37+
# Bill the final (arrears) cycle right away instead of waiting for the periodic
38+
# clock — the final invoice should land on termination, matching the legacy engine.
39+
after_commit { BillingCycles::BillSubscriptionJob.perform_later(subscription) }
40+
3741
result.subscription = subscription
3842
result
3943
end

clock.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ module Clockwork
3030
.perform_later
3131
end
3232

33+
# New billing engine outbox: producer schedules due cycles, consumer drains them into
34+
# invoices. Both are cheap keyset scans that fan out per-customer jobs, so they run
35+
# frequently for low billing latency.
36+
every(5.minutes, "schedule:create_billing_cycles") do
37+
Clock::CreateBillingCyclesJob
38+
.set(sentry: {"slug" => "lago_create_billing_cycles", "cron" => "*/5 * * * *"})
39+
.perform_later
40+
end
41+
42+
every(5.minutes, "schedule:process_billing_cycles") do
43+
Clock::ProcessBillingCyclesJob
44+
.set(sentry: {"slug" => "lago_process_billing_cycles", "cron" => "*/5 * * * *"})
45+
.perform_later
46+
end
47+
3348
subscription_activity_processing_interval = ENV["LAGO_SUBSCRIPTION_ACTIVITY_PROCESSING_INTERVAL_SECONDS"].presence || 1.minute
3449
every(subscription_activity_processing_interval.to_i.seconds, "schedule:process_subscription_activity") do
3550
Clock::ProcessAllSubscriptionActivitiesJob

0 commit comments

Comments
 (0)