Skip to content

[Feature]: Sticky routing — route each customer to their last successful connector #393

Description

@prajjwalkumar17

Note

Updated 2026-09-08. The design below supersedes the original proposal (visible in the edit history). What changed during design review and implementation: the store keeps a net habit count per connector (successes +1, gateway failures −1, floored at zero) rather than a single last-successful value; there is no cross-combo fallback chain (transaction-level fallbacks already cover a missing combo); and configuration is purely the merchant-level feature flag (sticky is independent of the euclid rule store). Implementation is split across draft PRs #419/#420/#412/#422/#423/#424, one phase per PR.

Problem

Merchants operating in markets where one payment method is served by multiple connectors (e.g. Interac in Canada via multiple providers) observe that a customer's payment is significantly more likely to succeed on the connector where that customer previously succeeded — provider user journeys differ, and returning users recognize the flow and complete it, while an unfamiliar form makes them drop off.

This applies to any method with multiple providers (Interac, GooglePay, ApplePay, …) and cannot be expressed today except by hand-writing one euclid metadata rule per customer — exact string equality only, O(rules × conditions) interpretation per payment, and a full deactivate→rewrite→reactivate cycle to change one customer's pin. Nor can it be derived from existing data: no Redis key and no ClickHouse column carries a customer dimension (the SR-v3 windows store anonymous 0/1 outcomes; the analytics pipeline is lossy and Kafka-only). Sticky routing therefore needs a new, small, TTL'd keyed store fed by the success feedback the engine already receives.

Design (as implemented)

The Redis entry (#413)

One hash per (merchant, customer); the nested model merchant:customer → { pm:pmt → { connector → net_count } } (successes +1, gateway failures −1, floored at zero — failures never create a key or field; only positive counts can pin) is flattened into fields so HINCRBY stays atomic:

Key:    sticky_gw_{merchant_id}_{customer_id}
Field:  {PM}:{PMT}:{connector}     e.g.  INTERAC:RTP:GIGADAT → "3"

PM/PMT are case-folded at the key boundary (payload- and snapshot-sourced writes must converge); the connector stays verbatim (it is compared against the caller's eligibleGatewayList at read time). Measured on Redis 7.2.7: 136 B for a typical 2-combo customer, 952 B at the combo cap, listpack-encoded — ~150–250 MB per 1M active customers.

TTLs and bounds (all service-config overridable):

Knob Key Default
Sliding key TTL (re-armed atomically with every write) STICKY_ROUTING_KEY_TTL 90 days
Combo fields per customer (lowest-count pruned) STICKY_ROUTING_MAX_COMBOS_PER_CUSTOMER 30
New customer hashes per merchant per TTL window STICKY_ROUTING_MAX_CUSTOMERS_{mid} 1,000,000
Write-dedupe NX lock sticky_gw_lock_{mid}_{payment} 1800 s
Health-veto score ratio at read time STICKY_ROUTING_MIN_SCORE_RATIO 0.5

Every sticky key is volatile, growth is admission-bounded up front, and each hash stays under the listpack threshold — sticky state cannot crowd SR/elimination keys out of Redis.

Config: a merchant feature flag (#414)

Sticky routing is independent of the euclid rule store. Its entire configuration is the merchant-level sticky_routing_enabled FeatureConf (rollout-% capable), exposed as KnownFeature::StickyRouting (slug sticky-routing) on the standard features API and surfaced as a card in the dashboard's Multi Objective tab → Feature Flags beside cost savings and volume contracts (#421). A per-request stickyRouting override on decide-gateway (mirroring enableMultiObjective) opts a single payment out.

Write path — update-gateway-score (#415)

Optional, backward-compatible customerId / paymentMethod / paymentMethodType on the feedback call; the decide-time snapshot (now stashing customerId) is the fallback. CHARGED/AUTHORIZED/PARTIAL_CHARGED add one, failure statuses (AUTHENTICATION_FAILED/AUTHORIZATION_FAILED/JUSPAY_DECLINED/FAILURE) subtract one; wider lifecycle statuses touch neither. Writes run before the snapshot fetch so late webhooks still count. Deliberately no dedupe: every feedback event counts, matching default SR scoring behavior. Retries are correct by construction: fail-on-A subtracts only from an existing habit on A; the succeeding attempt's connector gains.

Read path — decide-time override (#416)

Runs last in run_decider_flow, after SR scoring, elimination/outage penalties, and the cost/volume post-steps. Precedence: PL/euclid explicit orders first (they bound the candidate set and are never overridden), then sticky > SR > default — the gate admits the SR-selection family and the Default approach, so a pin still wins when SR is off; downtime relabels and hedging exploration stay untouched. A pin applies iff: feature flag on, customerId present, the request's stickyRouting override isn't false, and the connector clears the health veto (present in the eligible list, post-elimination score ≥ ratio × top). Every applied pin reports routing_approach: STICKY_ROUTING — admitted by the SRv3 producer-isolation and explore/exploit scoring gates so pinned outcomes keep feeding the SR windows (otherwise the pinned connector's score freezes and the veto can never trip); agree-vs-diverge stays measurable via the sticky decision metrics.

Status

Phases 1–3 are implemented, one phase per PR, all targeting main (merge in order; each PR body names the single commit to review — later branches contain their predecessors until rebased post-merge):

  1. feat(routing): sticky routing storage — per-customer success counts #419 — Redis storage layer + per-algorithm metadata config (dfa47ea)
  2. feat(routing): record sticky-routing outcomes from the feedback path #420 — feedback write path (9c50f17)
  3. feat(routing): apply sticky routing in the decide flow #412 — decide-side override (a7393e4)

Sub-issues #413/#415/#416 auto-close as these merge. Ops hardening (#417), the dashboard toggle (#421), and stack-level testing (#418) remain.

Tasks

Activity

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions