Skip to content

[ING-476] fix(lifetime_usage): retry wallet lock inline#5940

Open
mariohd wants to merge 1 commit into
mainfrom
ING-476
Open

[ING-476] fix(lifetime_usage): retry wallet lock inline#5940
mariohd wants to merge 1 commit into
mainfrom
ING-476

Conversation

@mariohd

@mariohd mariohd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Context

When a subscription activity is processed, UsageMonitoring::ProcessSubscriptionActivityService runs the lifetime-usage recalculation inline via RecalculateAndCheckJob.perform_now(lifetime_usage, current_usage:). current_usage is a SubscriptionUsage struct, which ActiveJob cannot serialize (it is only safe under perform_now, which does not serialize arguments).

The job retries wallet lock conflicts (ActiveRecord::StaleObjectError, BaseLockService::FailedToAcquireLock) through ActiveJob's retry_on. When a wallet optimistic-lock conflict happens during the inline run, retry_on tries to re-enqueue the job for a retry, which serializes the arguments and fails on the SubscriptionUsage struct. The result is a misleading ActiveJob::SerializationError: Unsupported argument type: SubscriptionUsage that masks the real underlying StaleObjectError on Wallet.

Solution

Retry the lock conflict in-process on the inline path so it never reaches ActiveJob's retry_on. The async perform_later path (no current_usage) keeps relying on retry_on, so it does not hold a worker while waiting between attempts.

  • Split perform into two paths: with current_usage present, wrap the work in an in-process retry (MAX_LOCK_RETRY_ATTEMPTS, rand(0...MAX_LOCK_RETRY_DELAY) backoff); otherwise call the services directly and let retry_on handle retries.
  • On exhaustion, raise a dedicated InlineLockRetryExhausted error that is intentionally not listed in retry_on. It is raised with the cause chain broken (cause: nil) because retry_on also matches an exception's cause — otherwise the wrapped lock error would still trigger a retry and the same serialization failure. The original error is preserved in the message for observability.

Testing

  • New specs cover the inline current_usage path for both lock error classes: retries then resolves without raising ActiveJob::SerializationError, and exhausts to InlineLockRetryExhausted (never SerializationError).
  • Existing retry_on (async) and discard_on behavior is unchanged and still covered.
  • RecalculateAndCheckJob spec: 13 examples, 0 failures. ProcessSubscriptionActivityService spec: 12 examples, 0 failures. RuboCop clean on the changed files.

## Context

When a subscription activity is processed, the lifetime-usage
recalculation runs inline via perform_now with the current usage passed
as a SubscriptionUsage struct, which ActiveJob cannot serialize. The job
retries wallet lock conflicts (StaleObjectError, FailedToAcquireLock)
through ActiveJob's retry_on. On a wallet optimistic-lock conflict,
retry_on tried to re-enqueue the job and failed to serialize the struct,
raising ActiveJob::SerializationError and masking the real conflict.

## Description

Retry lock conflicts in-process on the inline path that carries
current_usage, so they never reach retry_on. The async perform_later
path (no current_usage) keeps relying on retry_on and does not hold a
worker while waiting. On exhaustion, raise a dedicated non-retryable
error with the cause chain broken, since retry_on also matches an
exception's cause; the original error is preserved in the message.
@mariohd mariohd requested review from a team, aquinofb and vincent-pochet and removed request for a team July 14, 2026 13:20

@toommz toommz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just call .to_h before enqueuing, and reconstruct the struct in the service when it is a Hash? It would save us from this inline retry logic 🤔

I really think the real problem here is passing a non-serializable object to our job layer.

@mariohd

mariohd commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

@toommz
https://github.com/getlago/lago-api/pull/5940/changes#diff-70f317eb4d1bc8c374d8ee7af381d011551a621fc57cf8221bfd385211871576R32

# NOTE: do not pass current usage with perform_later as it will be a huge JSON

The current_usage tends to be a super huge hash and if we serialize it, it may break the job schedule as well, so it has to be executed inline.
Here it mentions about perform_later, a retry is a form of perform_later, so we shouldn't serialize the params.
In order to have the retries for inline execution, we need to catch the exception and run inline again; checking if the max attempts wasn't reached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants