fix(usage): Count exact duplicate charges only once - #6035
Conversation
vincent-pochet
left a comment
There was a problem hiding this comment.
Looks okay to me, but I have two questions:
- From the PR description: " Invoice generation is not affected", not sure to understand what differs in this path. Can you explain the difference?
- With this change, the weight of the deduplication is applied on the current usage computation witch is today (by far) the most critical path of the application. To be honnest, I'm not sure of the new load this will add, specially on subscriptions with a lot of filters. Do you think this dedup could be done differently, before reaching the current usage computation path?
| charge.accepts_target_wallet, | ||
| charge.taxes.map(&:id).sort, | ||
| charge.applied_pricing_unit&.slice(:pricing_unit_id, :conversion_rate), | ||
| charge.filters.map { |f| [f.properties, f.values.map { |v| [v.billable_metric_filter_id, v.values] }.sort_by(&:to_s)] }.sort_by(&:to_s) |
There was a problem hiding this comment.
Maybe this could be enough:
| charge.filters.map { |f| [f.properties, f.values.map { |v| [v.billable_metric_filter_id, v.values] }.sort_by(&:to_s)] }.sort_by(&:to_s) | |
| charge.filters.map(&:to_h) |
There was a problem hiding this comment.
Improvements in the last commits:
3 steps of detecting duplicates with early exits - this preserves good performance for most cases.
Check if same billable metric -> Same everything else -> Same filters -> Duplicate found
From the PR description: " Invoice generation is not affected", not sure to understand what differs in this path. Can you explain the difference?
The invoicing part keeps the old behavior with possible duplicates during a short window. If billing runs during that window, the invoice would include both fees.
The current PR fixes only the specific bug with customer usage. The root-cause fix for that path is preventing duplicates at write time, planned as a follow-up PR.
With this change, the weight of the deduplication is applied on the current usage computation witch is today (by far) the most critical path of the application.
As parsing filters is the only potentially heavy part, added a comparison only between charges that are identical in everything else. If a duplicate is found, the dedup removes the duplicate fee computation - no performance impact in this case. The only possible performance impact is when 2 charges are equal in every field except filters.
Also filters are preloaded, meaning no additional DB query is added.
Do you think this dedup could be done differently, before reaching the current usage computation path?
Yes - fixing the root cause would be in the write path to avoid duplicates at all. This requires locking a plan, adding an additional uniqueness index, adding reconciliation as updates/deletes are async. This has wider scope and risk with current production data. Further investigation and a follow-up PR are needed.
Maybe this could be enough:
charge.filters.map(&:to_h)
Replaced with charge.filters.map { |f| [f.properties, f.invoice_display_name, f.to_h.sort] }.sort_by(&:to_s)
4465fce to
0513d90
Compare
Document why ChargeFilter#to_h alone cannot back the dedup signature. Signed-off-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com>
|
Thanks for the review — answers below, one code change. "Maybe this could be enough: The only change in this commit is a "Invoice generation is not affected — what differs in this path?" — invoicing reads the charges through its own fee-generation path, which this PR does not touch, so it keeps the current behaviour: if billing runs inside the short window where a plan holds duplicate charges, the invoice includes both fees. This PR only stops the duplicates from inflating displayed current usage. The real fix for both paths is preventing duplicate charges at write time, which is a wider change (plan locking, a uniqueness index, reconciliation for the async updates/deletes) and is worth its own PR. "The dedup weight lands on the most critical path — could it be done before current usage computation?" — agreed that's the right end state, and it's the write-time fix above rather than something that can move earlier within this path. On the cost here: the work is gated by three early exits, so subscriptions with many filters do not pay for it unless they actually have duplicates. Charges are first grouped by Leaving this as a draft — not resolving the threads. |
|
HOLD — the dedup drops charges that are only presumed duplicates, and the same over-count is knowingly left in the invoicing path; both are calls a human should sign off on.
Verified: CI green; scope is 2 files; the first dedup spec would fail before the change (both copies summed); all the usage/alert/wallet callers do funnel through this one service, so the reported symptom is covered. |
|
This change breaks a valid scenario with 2 charges added intentionally:
The "fix" breaks usage display when a customer intentionally configured a plan with duplicate charges. |
Context
Concurrent plan updates and the async parent-to-child charge cascade can leave a plan with several live copies of the same charge. Current usage summed every copy, which had an impact on ongoing usage and triggered an erroneous threshold wallet top-up.
Description
Deduplicate charges by their full billing definition before computing current usage: exact copies are counted once, charges differing in any billing-relevant attribute are preserved.
Invoice generation is not affected - this needs to be fixed in a follow-up PR.