You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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)
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.
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.
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.
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):
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 modelmerchant: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 soHINCRBYstays atomic: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
eligibleGatewayListat 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):
STICKY_ROUTING_KEY_TTLSTICKY_ROUTING_MAX_COMBOS_PER_CUSTOMERSTICKY_ROUTING_MAX_CUSTOMERS_{mid}sticky_gw_lock_{mid}_{payment}STICKY_ROUTING_MIN_SCORE_RATIOEvery 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_enabledFeatureConf (rollout-% capable), exposed asKnownFeature::StickyRouting(slugsticky-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-requeststickyRoutingoverride on decide-gateway (mirroringenableMultiObjective) opts a single payment out.Write path — update-gateway-score (#415)
Optional, backward-compatible
customerId/paymentMethod/paymentMethodTypeon the feedback call; the decide-time snapshot (now stashingcustomerId) is the fallback.CHARGED/AUTHORIZED/PARTIAL_CHARGEDadd 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,customerIdpresent, the request'sstickyRoutingoverride isn'tfalse, and the connector clears the health veto (present in the eligible list, post-elimination score ≥ ratio × top). Every applied pin reportsrouting_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):dfa47ea)9c50f17)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