Conversation
## 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.
toommz
left a comment
There was a problem hiding this comment.
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.
|
@toommz
The |
Context
When a subscription activity is processed,
UsageMonitoring::ProcessSubscriptionActivityServiceruns the lifetime-usage recalculation inline viaRecalculateAndCheckJob.perform_now(lifetime_usage, current_usage:).current_usageis aSubscriptionUsagestruct, which ActiveJob cannot serialize (it is only safe underperform_now, which does not serialize arguments).The job retries wallet lock conflicts (
ActiveRecord::StaleObjectError,BaseLockService::FailedToAcquireLock) through ActiveJob'sretry_on. When a wallet optimistic-lock conflict happens during the inline run,retry_ontries to re-enqueue the job for a retry, which serializes the arguments and fails on theSubscriptionUsagestruct. The result is a misleadingActiveJob::SerializationError: Unsupported argument type: SubscriptionUsagethat masks the real underlyingStaleObjectErroronWallet.Solution
Retry the lock conflict in-process on the inline path so it never reaches ActiveJob's
retry_on. The asyncperform_laterpath (nocurrent_usage) keeps relying onretry_on, so it does not hold a worker while waiting between attempts.performinto two paths: withcurrent_usagepresent, 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 letretry_onhandle retries.InlineLockRetryExhaustederror that is intentionally not listed inretry_on. It is raised with the cause chain broken (cause: nil) becauseretry_onalso matches an exception'scause— 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
current_usagepath for both lock error classes: retries then resolves without raisingActiveJob::SerializationError, and exhausts toInlineLockRetryExhausted(neverSerializationError).retry_on(async) anddiscard_onbehavior is unchanged and still covered.RecalculateAndCheckJobspec: 13 examples, 0 failures.ProcessSubscriptionActivityServicespec: 12 examples, 0 failures. RuboCop clean on the changed files.