juspay / juspay/decision-engine

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

Open
#393 0 comments 0 reactions 1 assignee Claimed by @prajjwalkumar17 View on GitHub
T-Payment Methods & Routing
Dominant language
Rust
Stars
128
Forks
36
Avg merge
1d 15h
Merged PRs (30d)
34

Description

> [!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. #419 — Redis storage layer + per-algorithm metadata config (`dfa47ea`)
2. #420 — feedback write path (`9c50f17`)
3. #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

- [ ] #413
- [ ] #414
- [ ] #415
- [ ] #416
- [ ] #417
- [ ] #418
- [ ] #421

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.