test(routing): sticky routing e2e loop + dashboard-manageable kill switch - #422
Open
prajjwalkumar17 wants to merge 5 commits into
Open
test(routing): sticky routing e2e loop + dashboard-manageable kill switch#422prajjwalkumar17 wants to merge 5 commits into
prajjwalkumar17 wants to merge 5 commits into
Conversation
This was referenced Sep 4, 2026
prajjwalkumar17
force-pushed
the
feat/sticky-routing-tests
branch
from
September 6, 2026 08:48
0469f49 to
76a3ca1
Compare
This was referenced Sep 6, 2026
prajjwalkumar17
force-pushed
the
feat/sticky-routing-tests
branch
from
September 8, 2026 10:15
76a3ca1 to
59c2879
Compare
…r scores Storage layer only; feedback writes and the decide-side read come in the stacked follow-ups. - One Redis hash per (merchant, customer): fields pm:pmt:connector hold a NET count — successes increment, gateway failures decrement (floored at zero; merchant feedback is the source of truth in both directions). Failures never create a key or field, so key-creation stays tied to successful payments and the footprint stays bounded. Only positive counts qualify as pin candidates. Read is a single HGETALL returning counts sorted per exact combo; deliberately no cross-combo fallback (transaction-level fallbacks already cover a missing combo). - Eviction safety: every key volatile with a sliding TTL re-armed only on success; per-customer combo cap with lowest-count pruning that drains full excess; per-merchant two-window admission budget so new-key growth is bounded up front. Defaults overridable via service config. - Sticky on/off is a merchant-level feature flag (sticky_routing_enabled FeatureConf, dashboard plumbing in a follow-up) — deliberately NOT part of the euclid rule store, so toggling never touches the algorithm lifecycle. - New wrapper commands: hincrby_with_expire, hincrby, hgetall_map, hdel_field. Measured: 2-combo customer = 136B, 30-combo = 952B, listpack-encoded (Redis 7.2.7). Refs #393. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
prajjwalkumar17
force-pushed
the
feat/sticky-routing-tests
branch
from
September 8, 2026 10:51
59c2879 to
6771b55
Compare
7 tasks
Write side of sticky routing; the decide-side read comes next in the stack. Merchant feedback is the source of truth in both directions: successes (CHARGED/AUTHORIZED/PARTIAL_CHARGED) increment the habit, failure statuses (AUTHENTICATION_FAILED/AUTHORIZATION_FAILED/ JUSPAY_DECLINED/FAILURE) decrement it, floored at zero. Wider lifecycle statuses (VOIDED, AUTO_REFUNDED, ...) touch neither direction. Failures never create a key or field. - update-gateway-score gains optional customerId/paymentMethod/ paymentMethodType (backward compatible). Payload wins; the decide-time GatewayScoringData snapshot (now also carrying customerId) is the fallback, so callers need the new fields only when webhooks can outlive the snapshot's 30-min TTL. - Two-pass hook in check_and_update_gateway_score_: payload-only pass before the snapshot fetch (survives snapshot expiry, same rationale as the ab-test emit), snapshot pass after. - Deliberately NO dedupe: every feedback event counts, N times if sent N times — matching the engine's default SR scoring behavior (its feedback locks are opt-in per merchant), and avoiding a lock key per payment on the hot path. - GSM healthy-failure early return is now failure-only: a success with errorInfo previously skipped both the SR reward and any snapshot-based sticky write. - Sticky key pm/pmt are case-folded at the key boundary: the snapshot stores them uppercased while payloads arrive verbatim, and the two write sources must land on one hash field. Connector stays verbatim for the eligible-list comparison at decide time. - openapi + api-ref updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read side of sticky routing, completing the loop over the two prior commits (storage, feedback writes). Sticky is independent of the euclid rule store: it is gated only by the merchant-level sticky_routing_enabled feature flag, a customerId on the request, and an optional per-request stickyRouting override (mirroring enableMultiObjective). - New post-scoring override in run_decider_flow, after the cost and volume nudges: pin the customer's highest-success-count connector for the pm:pmt combo. Precedence is PL/euclid explicit orders first (they bound the candidate set and are never overridden), then sticky, then SR, then the default ordering — sticky applies over the SR-selection family AND the Default approach, so a pin still wins when SR is off. Downtime relabels and hedging exploration stay untouched. The pinned connector must clear a configurable fraction of the top score (STICKY_ROUTING_MIN_SCORE_RATIO, default 0.5), so a pin can never resurrect a connector the outage/elimination passes just buried. Fails open on any miss. - Every applied pin reports the new STICKY_ROUTING approach — a returning customer's repeat combo reading as SR would confuse callers. The label is admitted by the SRv3 producer-isolation and explore/exploit gates (mirroring the SR_SELECTION_MULTI_OBJECTIVE precedent), so pinned outcomes keep feeding the SR windows and the health veto stays live. A divergent pin additionally clears the superseded multi-objective/volume-steer claims so cost analytics can't credit a pick that didn't reach the customer. - Sticky keys trim merchant/customer ids; customerId is documented as byte-exact, and the UPI doc guidance matches the V2 flow's actual key material. Refs #393. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KnownFeature::StickyRouting (slug "sticky-routing") maps to the sticky_routing_enabled FeatureConf — the same key the feedback write and decide read already check — so the ops kill switch gets the standard features API/dashboard treatment with rollout-percentage support. Partially addresses #417. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six Playwright API tests against the live stack covering decide -> update-gateway-score -> decide: a recorded success pins the customer, a retried payment sticks to the finally-succeeding connector, the stickyRouting:false opt-out and a disabled merchant flag suppress the pin, sticky state is scoped to the payment-method combo, and a late webhook carrying inline customer/pm fields records without the decide-time snapshot. Score writes are async server-side, so pin assertions poll. Closes #418's live-stack scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
prajjwalkumar17
force-pushed
the
feat/sticky-routing-tests
branch
from
September 9, 2026 08:45
6771b55 to
35b5def
Compare
prajjwalkumar17
marked this pull request as ready for review
September 9, 2026 10:31
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.
Phase 4 for sticky routing (#393) — the kill switch made operable, and the loop proven end-to-end. Merge order: #419 → #420 → #412 → this. Two commits, one concern each:
KnownFeature::StickyRouting(slugsticky-routing) — maps to thesticky_routing_enabledFeatureConf the write and read paths check. This flag is THE configuration for sticky routing (independent of the euclid rule store), on the standard/merchant-account/{id}/features/*API with rollout-percentage support. Closes Sticky routing: merchant feature-flag config (sticky_routing_enabled) #414; the dashboard card ships in feat(dashboard): sticky routing toggle in the Multi Objective feature flags #424.tests/api/routing/sticky-routing.spec.ts) — the full decide → update-gateway-score → decide loop against the live stack: a recorded success pins the customer; a retried payment sticks to the connector that finally succeeded;stickyRouting:falseand a disabled merchant flag suppress the pin; sticky state is scoped to the payment-method combo; a late webhook with inline customer/pm fields records without the decide-time snapshot. Score writes are async server-side, so pin assertions poll. All six pass locally in ~4 s.Closes #418.
Important
This branch builds on phases 1–3, so "Files changed" includes them until predecessors merge and this is rebased. Review commits
d5728a3(kill switch) and35b5def(e2e suite) only.🤖 Generated with Claude Code
How to test locally
Full manual walkthrough — stack boot (with or without analytics), the pin loop, duplicates/decrement/opt-out, ClickHouse verification, edge scenarios, reference table (click to expand)
1. Boot an isolated stack
Run on non-default ports so an existing dev server (8080/5173) is untouched. Routing and sticky do not depend on analytics — but with Kafka + ClickHouse up, Decision Audit shows every decision with its
STICKY_ROUTINGapproach (step 6).With analytics (Kafka + ClickHouse in Docker/OrbStack — skip
compose upif your dev stack already runs them):Without analytics (no containers; audit/analytics pages stay empty):
DECISION_ENGINE__SERVER__PORT=8092 \ DECISION_ENGINE__METRICS__PORT=9195 \ DECISION_ENGINE__ANALYTICS__CLICKHOUSE__ENABLED=false \ DECISION_ENGINE__ANALYTICS__KAFKA__ENABLED=false \ ./target/debug/open_router &Optional dashboard (only needed for the toggle UI; the flag can also be flipped by API):
2. Create a fresh demo merchant + login
Dashboard login:
sticky_demo@example.com/Password123!athttp://localhost:5274.3. Enable the feature flag
Via dashboard: Multi Objective → Feature Flags → Sticky routing (pin returning customers) → Enable. (It sits beside Cost savings / Volume contracts — the flag is the entire config; sticky is independent of the euclid rule store.)
Or via API:
Verify:
4. The core loop: decide → succeed → pinned
Redis is empty before anything happens:
redis-cli KEYS 'sticky_gw_sticky_demo*'Payment 1 — plain SR decision (no sticky state yet):
It succeeds on loonio (feedback):
Inspect the state (writes are async — allow ~1 s):
redis-cli KEYS 'sticky_gw_sticky_demo*' redis-cli HGETALL sticky_gw_sticky_demo_cust_42 redis-cli TTL sticky_gw_sticky_demo_cust_42Expected: hash
sticky_gw_sticky_demo_cust_42with fieldINTERAC:RTP:loonio → 1; TTL ≈ 7,776,000 s (90 days, sliding — every success re-arms it). There is deliberately no dedupe: every feedback event counts — the same event sent twice moves the count twice (matching default SR scoring behavior).Payment 2 — same customer, now pinned:
Expected:
"decided_gateway": "loonio"and"routing_approach": "STICKY_ROUTING"(every applied pin reports this label). A second CHARGED feedback forpay_002bumps the count to2.Duplicates count — truth is truth. There is no dedupe: the same event sent twice moves the count twice (matching default SR scoring behavior):
Failures decrement (floor 0). A gateway-failure status subtracts one; at zero the pin releases. A failure on a connector with no habit changes nothing and creates no field:
Per-request opt-out. Add top-level
"stickyRouting": falseto any decide body — that payment routes by SR (approach ≠ STICKY_ROUTING) while the stored habit stays untouched.5. Watch the counters
curl -s http://localhost:9195/metrics | grep sticky_routingsticky_routing_decisions_total{outcome=…}:overridden(pin beat the SR head),pinned_agreeing(pin matched it),vetoed,no_state,read_errorsticky_routing_writes_total{outcome=…}:recorded,decremented,no_habit(failure with nothing to subtract),over_budget(admission guard),write_error6. Verify in analytics (with Kafka + ClickHouse up)
Every decide/feedback emits domain events; the Kafka → ClickHouse hop is async, so allow ~5 s:
The
decide_gateway_decisionrows carryrouting_approach = STICKY_ROUTINGfor pinned payments. The same data drives the dashboard's Decision Audit page — search a payment id there to see the full request → decision → feedback timeline.7. Behaviors worth testing
"stickyRouting": falseto the decide body"paymentMethodType":"CARD","paymentMethod":"VISA"AUTHORIZATION_FAILEDon gigadat, thenCHARGEDon loonioFAILUREon the pinned connector (new paymentId)FAILUREs on the pinned connector (other paymentIds) until its SR score < 0.5 × topvetoed; counts remain, habit resumes if it recovers8. Automated suites
9. Reference
sticky_gw_{merchantId}_{customerId}→ field{PM}:{PMT}:{connector}= net habit count (success +1, failure −1, floor 0)STICKY_ROUTING_KEY_TTLservice-config, default 90 dSTICKY_ROUTING_MAX_COMBOS_PER_CUSTOMER, default 30 (lowest-count pruned)STICKY_ROUTING_MAX_CUSTOMERS_{mid}, default 1,000,000STICKY_ROUTING_MIN_SCORE_RATIO, default 0.5sticky_routing_enabled(dashboard slugsticky-routing)Cleanup when done: