Skip to content

feat(revenue-recovery): A/B testing framework to compare retry algorithms#13328

Open
rohanm13 wants to merge 3 commits into
mainfrom
rev-rec-ab-testing-framework
Open

feat(revenue-recovery): A/B testing framework to compare retry algorithms#13328
rohanm13 wants to merge 3 commits into
mainfrom
rev-rec-ab-testing-framework

Conversation

@rohanm13

@rohanm13 rohanm13 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

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 separate
applicable-variants call is needed.

Changes:

  • core/revenue_recovery/routing.rs (new): the pure assignment core.
    RecoveryRoutingDecision { enabled, experiment_name, variant, algorithm } is the value
    resolved from Superposition; from_decision() maps it to
    AbRoutingResolution::{ Reused, Created, DisabledOrUnavailable, InvalidConfig } and to
    the persisted RevenueRecoveryRoutingData { experiment_name, variant, assigned_algorithm, assigned_at }.
  • Superposition config key revenue_recovery_routing wired through the existing
    dimension_config! macro — output RecoveryRoutingDecision, targeting key
    GlobalPaymentId, dimensions processor_merchant_id + connector, default
    { enabled: false }.
  • Assignment chokepoint in perform_calculate_workflow: reuse the persisted assignment
    if present (sticky — deliberately does not re-query Superposition), else resolve once and
    persist. The assigned algorithm then overrides retry_algorithm_type for that invoice.
  • Persistence via the existing feature_metadata (no new table): stored at
    feature_metadata.payment_revenue_recovery_metadata.recovery_routing, written by a
    persist_recovery_routing helper that updates only feature_metadata.
  • get_updated_feature_metadata now preserves recovery_routing — without this, every
    new failed-payment attempt rebuilds the recovery metadata and wipes the assignment,
    breaking stickiness.
  • TargetingKey impl for GlobalPaymentId in common_utils (required there by the
    orphan rule).

Fail-safe by design: with no active experiment the decision resolves to
enabled: false → no assignment → legacy routing is completely unchanged. An unparseable
algorithm 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 analytics
derives per-variant results by joining that with the persisted assignment:

select feature_metadata->'payment_revenue_recovery_metadata'->'recovery_routing'->>'variant',
       count(*),
       count(*) filter (where status = 'succeeded') as recovered
from payment_intent
where feature_metadata->'payment_revenue_recovery_metadata'->'recovery_routing' is not null
group by 1;

This avoids storing redundant data and avoids writing to an intent in a terminal
(succeeded) state, which the payments update operation rejects by design.

Operational note: a Superposition experiment only takes effect if at least one
user-created experiment group exists in the workspace. With only auto-generated system
groups, the client's context-less /experiment-config fetch returns no groups and routing
silently falls back to legacy.

Additional Changes

  • This PR modifies the API contract
  • This PR modifies the database schema
  • This PR modifies application configuration/environment variables

API contract: additive and backward-compatible only — a new optional
recovery_routing: Option<Value> field on PaymentRevenueRecoveryMetadata
(crates/api_models/src/payments.rs), which is ToSchema/openapi-exposed. No existing
field changed. Also adds an internal PaymentsUpdateIntentRequest::update_feature_metadata_with_api
constructor (no new endpoint).

No schema change: the assignment lives in the existing payment_intent.feature_metadata
JSONB column — no migration.

No config/env change: no files under config/, crates/router/src/configs, or
loadtest/config were modified. The experiment itself is authored in Superposition at
runtime, 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 pick smart or
cascading blind — there is no way to run both on live traffic and measure which actually
recovers more revenue.

This makes that comparison possible while guaranteeing the properties an experiment needs:

  • Deterministic — the same invoice always buckets to the same arm.
  • Sticky — the assignment is persisted once and reused for the invoice's entire recovery
    life, so an in-flight invoice never switches arms mid-retry even if the split changes.
  • Fail-safe — no experiment means byte-identical legacy behaviour.
  • Runtime-configurable — start/ramp/stop the split without a deploy.

How did you test it?

Unit tests (5, in core/revenue_recovery/routing.rs) covering from_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/recoveryCALCULATE_WORKFLOW:

  1. Assignment + split — 13 invoices routed through the real recovery flow produced both
    arms (7 control_cascading / 6 treatment_smart), each persisted to
    feature_metadata.recovery_routing with the correct experiment_name/variant/
    assigned_algorithm.
  2. Split ratio + determinism — resolving 1000 synthetic invoice ids through the exact
    production resolve path gave 50.9% / 49.1%, and re-resolving every id returned the
    same arm (200/200 stable).
  3. Stickiness (reuse) — replaced a persisted assignment with a sentinel and re-ran
    CALCULATE: the sentinel survived byte-for-byte and the log confirmed
    reused existing assignment with no Superposition re-query.
  4. Stickiness across attempts — firing additional failed-payment attempt webhooks at an
    assigned invoice keeps variant/assigned_at intact while total_retry_count still
    increments (this is the get_updated_feature_metadata fix; without it the assignment was
    wiped to null).
  5. Routing actually forkspt_mapping_pcr_retries is fetched only on the Cascading
    path. Across 6 invoices the correlation was exact: all 5 cascading-assigned invoices hit
    it, the smart-assigned one did not — proving the assignment drives which retry algorithm
    executes.
  6. Analytics derivation — the per-variant recovery query above returns correct
    invoices/recovered counts per arm with no stored outcome.
  7. Fail-safe — with the experiment not applying, every invoice resolved to
    enabled: false and 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

  • I formatted the code cargo +nightly fmt --all
  • I addressed lints thrown by cargo clippy
  • I reviewed the submitted code
  • I added unit tests for my changes where possible

@rohanm13
rohanm13 requested review from a team as code owners July 15, 2026 10:12
@semanticdiff-com

semanticdiff-com Bot commented Jul 15, 2026

Copy link
Copy Markdown

@rohanm13 rohanm13 changed the title resolve merge conflicts feat(revenue-recovery): A/B testing framework to compare retry algorithms Jul 15, 2026
@hyperswitch-bot hyperswitch-bot Bot added the M-api-contract-changes Metadata: This PR involves API contract changes label Jul 15, 2026
@rohanm13 rohanm13 added S-test-ready Status: This PR is ready for cypress-tests api-v2 and removed api-v2 labels Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

M-api-contract-changes Metadata: This PR involves API contract changes S-test-ready Status: This PR is ready for cypress-tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Revenue Recovery: A/B testing framework to compare retry algorithms (Smart vs Cascading) on live traffic

1 participant