feat(revenue-recovery): A/B testing framework to compare retry algorithms#13328
Open
rohanm13 wants to merge 3 commits into
Open
feat(revenue-recovery): A/B testing framework to compare retry algorithms#13328rohanm13 wants to merge 3 commits into
rohanm13 wants to merge 3 commits into
Conversation
Changed Files
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type of Change
Description
Adds an A/B testing framework for Revenue Recovery retry routing, so the Smart
(decider-based) and Cascading (rules-based) algorithms can run side by side on live
traffic and be compared on real recovered revenue.
The decision is made by Superposition's experiment engine, keyed on the invoice id
(
GlobalPaymentId) as the targeting key, which gives deterministic bucketing per invoice.Each experiment variant overrides the whole config value with its own
variant+algorithm, so a single resolve returns the assigned arm — no separateapplicable-variants call is needed.
Changes:
core/revenue_recovery/routing.rs(new): the pure assignment core.RecoveryRoutingDecision { enabled, experiment_name, variant, algorithm }is the valueresolved from Superposition;
from_decision()maps it toAbRoutingResolution::{ Reused, Created, DisabledOrUnavailable, InvalidConfig }and tothe persisted
RevenueRecoveryRoutingData { experiment_name, variant, assigned_algorithm, assigned_at }.revenue_recovery_routingwired through the existingdimension_config!macro — outputRecoveryRoutingDecision, targeting keyGlobalPaymentId, dimensionsprocessor_merchant_id+connector, default{ enabled: false }.perform_calculate_workflow: reuse the persisted assignmentif present (sticky — deliberately does not re-query Superposition), else resolve once and
persist. The assigned algorithm then overrides
retry_algorithm_typefor that invoice.feature_metadata(no new table): stored atfeature_metadata.payment_revenue_recovery_metadata.recovery_routing, written by apersist_recovery_routinghelper that updates onlyfeature_metadata.get_updated_feature_metadatanow preservesrecovery_routing— without this, everynew failed-payment attempt rebuilds the recovery metadata and wipes the assignment,
breaking stickiness.
TargetingKeyimpl forGlobalPaymentIdincommon_utils(required there by theorphan rule).
Fail-safe by design: with no active experiment the decision resolves to
enabled: false→ no assignment → legacy routing is completely unchanged. An unparseablealgorithm resolves to
InvalidConfig→ also legacy routing.The outcome is intentionally not stored. Whether an invoice recovered is already
authoritative on
payment_intent.status(with amount/timing on the intent), so analyticsderives per-variant results by joining that with the persisted assignment:
This avoids storing redundant data and avoids writing to an intent in a terminal
(
succeeded) state, which the payments update operation rejects by design.Additional Changes
API contract: additive and backward-compatible only — a new optional
recovery_routing: Option<Value>field onPaymentRevenueRecoveryMetadata(
crates/api_models/src/payments.rs), which isToSchema/openapi-exposed. No existingfield changed. Also adds an internal
PaymentsUpdateIntentRequest::update_feature_metadata_with_apiconstructor (no new endpoint).
No schema change: the assignment lives in the existing
payment_intent.feature_metadataJSONB column — no migration.
No config/env change: no files under
config/,crates/router/src/configs, orloadtest/configwere modified. The experiment itself is authored in Superposition atruntime, which is the point — the split can be started, ramped, or stopped without a deploy.
Motivation and Context
Closes #13324
Revenue Recovery routes every invoice through a single retry algorithm chosen per business
profile (
revenue_recovery_retry_algorithm_type). A merchant has to picksmartorcascadingblind — there is no way to run both on live traffic and measure which actuallyrecovers more revenue.
This makes that comparison possible while guaranteeing the properties an experiment needs:
life, so an in-flight invoice never switches arms mid-retry even if the split changes.
How did you test it?
Unit tests (5, in
core/revenue_recovery/routing.rs) coveringfrom_decision: disabled →legacy, default → legacy, enabled → assignment created, cascading arm, and unsupported
algorithm →
InvalidConfig.End-to-end against a live local stack (router + scheduler producer/consumer + Postgres +
Redis + Superposition), by onboarding a v2 merchant/profile/connectors and driving real
invoices through
POST /v2/payments/recovery→CALCULATE_WORKFLOW:arms (7
control_cascading/ 6treatment_smart), each persisted tofeature_metadata.recovery_routingwith the correctexperiment_name/variant/assigned_algorithm.production resolve path gave 50.9% / 49.1%, and re-resolving every id returned the
same arm (200/200 stable).
CALCULATE: the sentinel survived byte-for-byte and the log confirmedreused existing assignmentwith no Superposition re-query.assigned invoice keeps
variant/assigned_atintact whiletotal_retry_countstillincrements (this is the
get_updated_feature_metadatafix; without it the assignment waswiped to
null).pt_mapping_pcr_retriesis fetched only on the Cascadingpath. Across 6 invoices the correlation was exact: all 5
cascading-assigned invoices hitit, the
smart-assigned one did not — proving the assignment drives which retry algorithmexecutes.
invoices/recoveredcounts per arm with no stored outcome.enabled: falseand used legacy routing.Not covered: the Smart arm's decider gRPC call itself (the branch bails earlier when no PSP
tokens are present in Redis, and the decider service isn't run locally) — the fork is
verified, its internals are not.
Checklist
cargo +nightly fmt --allcargo clippy