<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Razorpay Engineering - Medium]]></title>
        <description><![CDATA[Razorpay’s Engineering blog, decoding how we build India’s Financial Infrastructure backbone - Medium]]></description>
        <link>https://engineering.razorpay.com?source=rss----6407ad2e59af---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Razorpay Engineering - Medium</title>
            <link>https://engineering.razorpay.com?source=rss----6407ad2e59af---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 13 Sep 2026 17:58:17 GMT</lastBuildDate>
        <atom:link href="https://engineering.razorpay.com/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Five Years of Kafka at Razorpay’s UPI Switch]]></title>
            <link>https://engineering.razorpay.com/tryst-with-kafka-2f5cef766c45?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/2f5cef766c45</guid>
            <dc:creator><![CDATA[Kshitij Nawandar]]></dc:creator>
            <pubDate>Mon, 07 Sep 2026 09:09:58 GMT</pubDate>
            <atom:updated>2026-09-07T09:11:05.291Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QX1DEQKJfuITx8nCMGMv8Q.png" /></figure><h3>Preface</h3><p>The UPI Switch at Razorpay has evolved significantly in the five years since we started building it. The Switch is the platform that enables real-time payment processing with NPCI. When the team began, it was little more than an idea. Today it powers more than <strong>70% of Razorpay’s total UPI volume</strong>.</p><p>Because UPI is inherently asynchronous, a messaging system sits at the heart of the Switch and has a direct impact on performance, reliability, and scale. What began as a straightforward queue became the core of the system, shaping how every new feature was designed and delivered. This post covers that evolution: the decisions that enabled growth, the ones that slowed us down, the operational issues that forced us to rethink our assumptions, and the optimizations that ultimately stabilized our Kafka-based infrastructure.</p><p>This is the story of what we got right, what we got wrong, and how we eventually built something stable enough to grow on.</p><h3>The First Version: Monolith and SQS</h3><p>When we began building the UPI Switch, we weren’t thinking about massive scale, distributed systems, or elegant event routing. So we built Switch v1 as a monolith. No microservices, no distributed orchestration: just one solid block of code doing everything. That was the right call. We needed to move fast, experiment, and learn, and we followed the Keep It Simple, Stupid (KISS) principle deliberately.</p><p>For messaging, we picked AWS SQS: reliable, managed, and low on cognitive load. We didn’t need ordering guarantees at the time, so a standard queue worked fine. We started with just two queues, and this setup held its ground. It handled a peak of <strong>400 TPS during the IPL</strong>.</p><p>The limitations showed up as the ecosystem grew. A single event, like a successful payment, needed to fan out into multiple workflows:</p><ol><li>Update NPCI with an API call</li><li>Send callbacks to merchants about payment status</li><li>Push structured data into our warehouse (AWS Redshift)</li></ol><p>To handle this, we started bolting on AWS SNS plus SQS for fan-out. It worked technically, but it felt improvised. The fan-out was a chain of pipes we kept extending as new requirements surfaced. We were still delivering and nothing was on fire, but the architecture was growing sideways rather than upward, and every addition came with operational drag.</p><h3>Moving to Event-driven Microservices</h3><p>The next target was bold: <strong>10,000 TPS</strong>. To get there, we had to rethink the fundamentals. We redesigned the system into Switch v2, moving from a monolith to an event-driven microservices architecture. We split the Switch into independently scalable components: customer and merchant services, VPA, payments, mandates, and others. Each service owned a specific domain and could scale horizontally without impacting the rest.</p><p>AWS SQS Standard had served us well, but it lacked two capabilities we now needed: true pub-sub behavior, and high-throughput with low-latency delivery. We wanted a system built for scale, and the answer pointed clearly toward Kafka. So we moved from AWS SQS to AWS MSK (Managed Kafka), which felt like the right fit for the new architecture.</p><h3>The Decisions We Made</h3><p>As Switch v2 took shape, we faced a series of architectural decisions. Each seemed reasonable, even elegant, at the time. Many of them would return later to cause real problems. The promises were genuine; the costs came later.</p><ol><li><strong>Choosing GoCloud Pub/Sub.</strong> We wanted to stay vendor-agnostic and avoid locking ourselves into Kafka or MSK. So instead of integrating with Kafka directly through a native client, we picked GoCloud Pub/Sub (gocloud.dev/pubsub), which gave us a common abstraction over multiple backends like SQS, Kafka, and NATS. The thinking was simple: if we ever wanted to move off Kafka, it should be easy.</li><li><strong>MSK Connector and the outbox promise.</strong> We wanted atomicity between persisting a new payment and publishing the corresponding event, because writing to the DB but failing to publish the event would leave the system inconsistent. To solve this, we leaned on the Outbox Pattern, where the application writes the business data (such as a payment) and the event to an outbox table within the same database transaction, and an external component (like AWS MSK Connector) reads from that table and publishes to Kafka.</li><li><strong>Betting on AWS MSK Serverless.</strong> Provisioning and scaling a Kafka cluster felt like unnecessary overhead. We wanted autoscaling with no broker-sizing decisions, support for unpredictable and elastic workloads, and pay-as-you-go pricing. So we picked AWS MSK Serverless over provisioned MSK clusters.</li><li><strong>DDD-inspired event modeling.</strong> We used event-storming (a collaborative workshop technique for mapping the events that occur across a business domain) along with domain-driven design (DDD) to break down the problem space. Each bounded context emitted its own events, and to keep domains cleanly separated, we created a Kafka topic for every event type.</li></ol><p>Individually, each decision seemed justified. Together, they shaped our architecture, sometimes for the better and sometimes in ways we would spend months undoing.</p><h3>Where It Broke at Scale</h3><p><strong>1. Topic explosion: event-storming gone too far.</strong> We embraced DDD and event-storming seriously. Every domain emitted its own events, and every event type received its own Kafka topic. Then came retry topics and DLQ topics. On top of that, we segregated traffic per product line (Acquiring, TPAP, Turbo) to ensure isolation.</p><p>Total partitions ≈ (#topics) × (replication factor) × (retry + DLQ topics) × (#product lines)</p><p>The partition count ballooned quickly. Although Kafka is “infinitely scalable” in theory, each broker has a practical limit on how many partitions it can host. Every partition consumes CPU and memory for metadata and file handles, replica sync and network I/O, and thread-scheduling overhead. As partition counts grow, brokers spend more time on bookkeeping (ISR management, replica fetch, leader election) than on actually moving data. This drives CPU spikes and throttling long before throughput becomes the bottleneck. In practice, scalability was limited not by throughput but by partitions per broker.</p><p><strong>2. MSK Connector: routing that didn’t scale.</strong> To achieve DB and Kafka atomicity, we wrote business records and events to a single database table, and MSK Connector would read the events table and publish to Kafka. However, MSK Connect’s source connectors read from a source system and write to a single configured topic; they don’t support multi-topic routing from a single source table. We tried to solve this by extending the connector with custom routing logic, dynamically pushing messages to different Kafka topics based on metadata inside the event row. This did not work well in practice, and the connector processes crashed frequently.</p><p><strong>3. MSK Serverless instability and faulty client behavior.</strong> We selected MSK Serverless to avoid operational overhead, but it introduced critical limitations.</p><ul><li>We observed severe connection imbalance. Out of three brokers, one consistently accumulated a disproportionately high number of TCP connections and significantly higher CPU utilization. This hotspot contributed to instability and throttling under load.</li><li>Unlike AWS Aurora RDS, which exposes a stable cluster endpoint and transparently handles node restarts, MSK Serverless only provides individual broker addresses, which means clients must handle broker restarts themselves.</li><li>MSK performs scheduled monthly maintenance during which brokers are restarted. Our consumers failed to reconnect to Kafka automatically during these periods, and recovery required manually restarting application pods.</li><li>The root cause was that gocloud.dev/pubsub did not re-resolve DNS on reconnect. It cached broker IPs at startup and kept using those stale addresses after maintenance, even when brokers came back up with new IPs. As a result, clients failed to reconnect until pods were restarted. This broke self-healing and caused rolling broker restarts to cascade into application outages, undermining Kafka’s high-availability guarantees.</li></ul><p><strong>4.</strong> The abstraction introduced by the GoCloud Pub/Sub layer also limited our ability to configure Kafka-level client parameters that could have improved connection management and distribution, which prevented us from mitigating the imbalance at the client layer.</p><p><strong>5. IAM auth connection cap: the Asia Cup incident.</strong> Ahead of the Asia Cup 2025 surge, we scaled out our pods assuming we could absorb the expected traffic. We were using MSK IAM authentication, which enforces a limit of roughly <strong>3,000 active TCP connections per broker</strong>. Unaware of this constraint, and combined with GoCloud Pub/Sub’s tendency to create excessive connections, we saturated the connection quota almost immediately.</p><h3>6. How we fixed it</h3><ol><li><strong>Migrating from GoCloud Pub/Sub to franz-go.</strong> The first and most important change was replacing gocloud.dev/pubsub with franz-go, a Kafka-native Go client. franz-go gave us full Kafka protocol support, DNS re-resolution on reconnect, efficient connection handling, better batching and compression support, fine-grained control over producers and consumers, and better instrumentation and metrics exposure.</li><li>The impact was immediate. DNS was re-resolved correctly, so clients auto-recovered after broker restarts. Broker load became evenly distributed across CPU and connection count. Connection count dropped from <strong>~1,100 to ~300 per broker</strong> in production [VERIFY], and publish latency fell from <strong>~250 ms to ~10 ms</strong> [VERIFY]. The client-library swap alone neutralized most of our critical pain points.</li><li><strong>Consumer-side batch processing with worker pools.</strong> Partition count had become a limiting factor, since partitions define the upper bound of concurrency, and more partitions meant more broker overhead. To break this coupling, we introduced worker pools per consumer: messages are fetched sequentially per partition, processing is parallelized using goroutine workers, and a batch offset commit is issued after the workers finish. This preserved per-partition ordering guarantees while improving compute utilization.</li><li><strong>Reconnection and retry handling.</strong> Even though franz-go handles consumer and producer reconnection internally, we added guard rails with additional retry and reconnect logic around consumer polling for defensive recovery. This made the consumer layer resilient to flap events, network jitter, and rolling restarts. The combination of native recovery and defensive logic made failures non-catastrophic and eliminated the need for manual pod restarts.</li><li><strong>TCP pooling via a custom TLS dialer.</strong> Unlike SQL clients, Kafka clients usually do not provide built-in TCP connection pooling, and the default behavior often leads to excessive, short-lived TCP connections. We introduced a custom TLS dialer, a controlled TCP connection pool, and reuse of existing network connections.</li><li><strong>Topic consolidation and infra isolation.</strong> The one-to-one mapping between event types and Kafka topics was the root cause of our partition explosion. We redesigned the event model: instead of a dedicated topic per event type, we grouped related events from the same domain into a single Kafka topic. For example, all events associated with the VPA and Bank Account modules (registrations, validations, deletions, updates) now go to just two Kafka topics, and consumer-side filtering on event-type metadata handles the differentiation within each topic.</li><li>Reducing topics alone is not enough if high-traffic flows can starve latency-sensitive ones. VPA validation, for instance, is a resource-intensive API call with external service latency of up to 15 seconds. Grouping it with financial flows like payments would let it monopolize partitions and consumers, with cascading effects on payment throughput. To address this, we split our gateway NPCI module (the service responsible for all egress to NPCI) into separate, independently deployable components: VPA_GwNPCI, Payments_GwNPCI, and Mandates_GwNPCI. Each component has its own dedicated Kafka topics, consumer groups, and compute resources. Non-financial flows like VPA validation and financial flows like payment processing are now isolated at the infrastructure level and cannot interfere with each other’s throughput or latency budgets.</li><li><strong>Migrating to MSK Provisioned with SASL/SCRAM auth.</strong> Finally, we moved from MSK Serverless with IAM auth to MSK Provisioned with SASL/SCRAM, which does not impose the same strict limits on connection counts.</li></ol><p>Individually, each optimization solved a specific issue: connection churn, uneven broker load, or limited concurrency. Together, they shifted us from reacting to failures to reliably operating at scale. Kafka stopped feeling like a constant negotiation and started acting like the backbone we needed.</p><h3>The Benchmarks After the Rebuild</h3><p>We ran extensive performance and load tests to validate the improvements, iterating on configuration tuning across producers, consumers, batching parameters, and network settings to find the right balance of throughput and stability. We benchmarked several flows individually and also ran mixed-load scenarios to simulate real production traffic patterns:</p><ol><li>Intent payments</li><li>Mandate creation</li><li>VPA validation</li><li>Online refunds</li></ol><p><strong>Performance highlights:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6mlhLH1xjeYYLBHfQjVaiQ.png" /></figure><p>These numbers showed that we had regained performance headroom in both throughput and broker capacity. The cluster was stable, predictable, and responsive even under mixed workloads.</p><h3>What We Would Say to Other Teams Building This</h3><p>A few lessons generalize beyond our setup.</p><ol><li><strong>Vendor-agnostic abstractions can hide the things you actually need to configure.</strong> GoCloud Pub/Sub gave us portability on paper, but it also hid Kafka-level client controls (DNS re-resolution, connection management, batching) that turned out to be exactly what we needed at scale. A thin abstraction is fine until the layer you abstracted away is the layer where your production problems live.</li><li><strong>One topic per event type is rarely the right granularity.</strong> Modeling each event type as its own topic looks clean in a domain diagram, but it pushes partition counts past what brokers can handle. Group related events by domain and filter on the consumer side instead.</li><li><strong>Connection management on async messaging clients is where scale problems hide.</strong> Throughput numbers get all the attention, but the failures that took us down were about connections: imbalance across brokers, stale DNS after restarts, and hard caps on connection counts. Treat connection behavior as a first-class concern when you pick and configure a client.</li></ol><h3>What’s Next</h3><p>Even after the major architectural improvements and performance wins, some areas still need attention. These aren’t failures; they’re the natural next steps in maturing the platform.</p><ol><li><strong>Idempotency on consumers.</strong> Kafka can replay messages during consumer restarts, partition rebalances, offset resets, and retention recovery. To guarantee correct processing in all cases, we need idempotent consumption, ensuring the same event produces the same effect only once even if received multiple times. Our approach: assign a durable idempotency key to every message at publish time (for example, a composite of payment ID and event type). On the consumer side, before processing any message, check a deduplication store (Redis or DB) for the key. If it has already been processed, skip it; if not, process it and record the key. This makes consumer behavior safe under any replay scenario.</li><li><strong>Atomic writes (post MSK Connector).</strong> After removing the MSK Connector, we lost DB and Kafka atomicity. Today, writes to the DB and publishes to Kafka are separate operations, which creates windowed failure cases: a DB write succeeds but the Kafka publish fails, or a Kafka publish succeeds but the DB write rolls back.</li><li><strong>Retries and DLQs.</strong> We need a consistent error-handling framework across consumers. Consumers should decide which errors are retryable, define backoff behavior (fixed, exponential, max attempts), and determine when a message should move to a DLQ instead of being retried indefinitely. A structured retry and DLQ policy will prevent infinite retry loops, isolate poison messages, and make failure handling predictable.</li></ol><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2f5cef766c45" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/tryst-with-kafka-2f5cef766c45">Five Years of Kafka at Razorpay’s UPI Switch</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How Razorpay Cut Its Metrics Bill by 62% Without Losing a Dashboard]]></title>
            <link>https://engineering.razorpay.com/how-razorpay-cut-its-metrics-bill-by-62-without-losing-a-dashboard-2a7d5467df37?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/2a7d5467df37</guid>
            <dc:creator><![CDATA[Dhairya Mehta]]></dc:creator>
            <pubDate>Mon, 24 Aug 2026 10:25:21 GMT</pubDate>
            <atom:updated>2026-08-24T10:25:20.346Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QLNSvynBOdka-JuWB9HR_g.png" /></figure><p><em>Contributor: </em><a href="https://medium.com/u/7f2fdf82e5d9"><em>Saijal Shrivastava</em></a></p><p>We cut our metrics ingestion by 62% without deleting a single useful dashboard or breaking an alert.</p><p>Daily ingestion dropped from about 450 billion samples to about 170 billion. The surprising part was not one large optimization. It was three simple discoveries hiding in plain sight:</p><ol><li>Scrape intervals can multiply ingestion.</li><li>High-availability scraping can silently send duplicate metrics when deduplication happens only at the storage layer.</li><li>High-cardinality metrics are easy to create, expensive to keep, and hard to remove without ownership.</li></ol><p>We found these while preparing to move from a self-hosted VictoriaMetrics cluster to a managed monitoring platform. The migration started as a reliability project. The audit turned it into a cost and signal-quality project. We were not just moving monitoring data. We were moving years of accumulated assumptions about what was worth scraping.</p><h3>The Incident that Forced the Audit</h3><p>Our self-hosted VictoriaMetrics cluster had served us well for years. Then one storage node hit EBS volume throttling. Traffic shifted to the remaining storage nodes, the extra load pushed another node past its limits, and the storage layer collapsed. We saw the same pattern whenever a storage node became unavailable: traffic redistributed, the remaining nodes overloaded, and the cluster moved back toward failure.</p><p>The collection, ingestion, and query components were still running, so the system looked alive from the outside. But the storage layer was down. Metrics stopped being persisted. Dashboards went blank. Alerts stopped evaluating. Recovery took hours. Teams fell back to logs and manual checks to understand production health.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cMKn6VBVqcJro6OEThIMcw.png" /><figcaption><strong><em>Figure 1</em></strong><em>: </em><strong><em>Cascading failure in the pipeline</em></strong></figcaption></figure><p>That made the reliability problem obvious. We needed a more stable monitoring architecture. We evaluated several options: self-hosted designs with disaster recovery built in, and fully managed alternatives. But when we priced the managed options, one number changed the project: at roughly 400 billion daily samples, the estimates ran into hundreds of thousands of dollars per month. At that scale, every duplicate scrape, every unnecessary 30-second interval, and every unbounded label had a direct cost. So before migrating, we audited what we were actually shipping.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eNvGBc6bx5xLqkQnzHxn6g.png" /><figcaption><strong><em>Figure 2: Monitoring Architecture</em></strong></figcaption></figure><h3>Discovery 1: Scrape Interval is a Multiplier</h3><p>The easiest problem to miss was scrape interval.</p><p>Our infrastructure metrics were scraped every 30 seconds. Application metrics were scraped every 60 seconds. That meant infra agents produced twice as many samples per metric before we even looked at metric count or label cardinality.</p><p>A 30-second scrape interval sounds harmless when applied to one target. At fleet scale, it becomes a multiplier across every node, pod, container, and exported metric. If the data is not used at that resolution, the extra samples add cost without improving monitoring.</p><p>We audited alert rules and dashboards to check whether anything depended on sub-minute resolution. Nothing did. Capacity planning, node health alerting, and resource utilization were all fine at one-minute resolution.</p><p>So we standardized infra scraping to 60 seconds.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0lNMpPnMOJvgpyBy71rx6g.png" /><figcaption><strong><em>Figure 3: Scraping interval for every series sample.</em></strong></figcaption></figure><p>This one change halved infra metric ingestion -</p><ul><li>No dashboards went dark.</li><li>No alerts broke.</li></ul><p>The data became cheaper without becoming less useful.The lesson was simple: scrape interval is not a default. It is a cost and fidelity decision.</p><h3>Discovery 2: High-availability Scraping can Hide Duplicate Ingestion</h3><p>The biggest discovery was duplicate ingestion.</p><p>Our application metrics used high-availability vmagent pairs: two replicas per business unit, both scraping the same targets. This is a common pattern. It protects monitoring from agent failure, and with self-hosted VictoriaMetrics it appears to work cleanly because VictoriaMetrics deduplicates identical samples at the storage layer.</p><p>That storage-layer detail matters.</p><p>Deduplication at storage means the duplicates are hidden from queries. Dashboards look correct. Alerts behave correctly. Nobody looking at Grafana sees the duplication.</p><p>But the duplicate samples still travel through the monitoring pipeline. Both vmagent replicas scrape the same targets. Both push the same samples. The system spends compute, network, and ingestion capacity on both copies.</p><p>In other words, storage deduplication protected correctness, not cost.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5ZABbSuX7fxRyg_G3NO5ag.png" /><figcaption><strong><em>Figure 4: High Availability ingestion strategy</em></strong></figcaption></figure><p>At our scale, that distinction mattered. Hundreds of billions of application samples per day were being scraped twice and pushed twice.</p><p>We could have switched scraping tools, but that would have meant changing service discovery, relabeling pipelines, and scrape configuration across teams. That would have turned a monitoring migration into a large coordination project.</p><p>We wanted three things at once:</p><ul><li>keep vmagent,</li><li>preserve high availability,</li><li>ensure only one replica scraped at a time.</li></ul><p>VictoriaMetrics did not provide agent-level active-standby scraping, so we built it: one agent scrapes while the other waits.</p><h3>Making vmagent active-standby</h3><p>We added a leader-election sidecar to each vmagent pod. Both pods stay running, but only the leader scrapes. The follower stays ready to take over.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8au3XM8DHSAV4IUMuwaBvQ.png" /><figcaption><strong><em>Figure 5: Active — Passive scraping using Kubernetes lease</em></strong></figcaption></figure><p>The design has three parts:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*B9QzK5KLXh70H9NtPaqL_w.png" /></figure><p>vmagent reads its scrape configuration through a symlink. The sidecar controls where that symlink points.</p><p>When the pod is leader, the symlink points to the real scrape config. When the pod is follower, it points to the dummy config. On leadership changes, the sidecar updates the symlink and sends SIGHUP to vmagent, which reloads config without a container restart.</p><p>The dangerous failure mode is split brain: both pods believing they are leader and scraping the same targets. That would recreate the duplication we were trying to remove.</p><p>So the system is biased toward stopping scrapes rather than risking duplicates:</p><ul><li>If the sidecar cannot reach the Kubernetes API, it assumes it is not leader and switches to the dummy config.</li><li>Two failed lease renewals trigger immediate self-demotion.</li><li>A lease expires after 15 seconds without renewal.</li><li>One failed renewal is tolerated as a grace period, but not two.</li></ul><p>In graceful shutdown tests, failover took about seven seconds. In hard-crash tests, it took about 22 seconds. In network partition tests, the old leader demoted itself before the follower took over, preventing duplicate scrapes. The sidecar has now run in production for more than six months with no split-brain incidents.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TDLxAeiT1-MkH1bMFzdZYg.png" /><figcaption><strong><em>Figure 6: Failover behaviour and Split-brain protection</em></strong></figcaption></figure><h3>Discovery 3: High-cardinality Metrics Need an Ownership Model</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*d-Ubs9Wd0xr0YYMOF5p2rQ.png" /><figcaption><strong><em>Figure 7: The gravity of high cardinality</em></strong></figcaption></figure><p>The third problem was not duplication. It was noise.</p><p>We analyzed the top 500 highest-cardinality metrics against actual usage in alerts and dashboards over 90 days. We found 167 metrics that were not used anywhere: not in an alert, not on a dashboard, and not in observed queries.</p><p>They were still being scraped, shipped, stored, and paid for.</p><p>High-cardinality metrics are especially dangerous because they often look reasonable at creation time. A label such as url, uri, user_id, merchant_id, or raw request path can multiply one metric into millions of time series. The exporter still works. The dashboard may still load. But the monitoring system now carries a large amount of data that few people understand and even fewer people use.</p><p>Without an effective strategy, high-cardinality metrics become permanent. Teams are afraid to remove them because someone might depend on them. Platform teams are afraid to drop them because ownership is unclear. So the safest local decision is to keep everything, and the global result is an expensive monitoring system full of unused series.</p><p>We dropped unused metrics at the vmagent layer through relabeling rules. That gave us one reversible control point instead of waiting for every application team to release code, and removed about 50 billion samples per day.</p><p>A high-cardinality metric is not automatically bad, but it must justify its cost. If nobody uses it, owns it, or knows why it exists, it should not be in the default monitoring path.</p><h3>The Result Post Migration</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cwoTHhGJYXFgi_gUaj-9mg.png" /></figure><p>The most valuable outcome was not just the lower bill. The monitoring system became easier to reason about.</p><p>We knew which metrics were duplicated. We knew which scrape intervals were intentional. We knew which high-cardinality metrics were justified. And we had a cleaner path for future migrations because the proxy absorbed long-tail consumers.</p><h3>What We Would Tell Other Teams</h3><p>Audit your systems regularly. A migration makes cost visible, but the waste usually existed long before the migration started.</p><p>Treat scrape interval as a multiplier. If a metric does not need 30-second resolution, every extra scrape is cost without signal.</p><p>Do not assume high-availability scraping is free. Storage-level deduplication may make dashboards correct, but it does not remove the compute, network, or ingestion cost of duplicate samples.</p><p>Give high-cardinality metrics an ownership model. If a metric creates millions of series, someone should know why it exists, where it is used, and when it can be removed.</p><p>Optimize at the edge when possible. Dropping unused metrics and preventing duplicate scrapes at vmagent reduced ingestion before data entered the rest of the monitoring pipeline.</p><p>We started with a reliability incident. We ended with a monitoring system that was more stable, easier to operate, and 62% cheaper to run. The biggest win was not moving data to a new platform. It was learning which data deserved to move at all.</p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2a7d5467df37" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/how-razorpay-cut-its-metrics-bill-by-62-without-losing-a-dashboard-2a7d5467df37">How Razorpay Cut Its Metrics Bill by 62% Without Losing a Dashboard</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[CI Doesn’t Need On-Demand: Moving Our Build Pipelines to Spot Instances]]></title>
            <link>https://engineering.razorpay.com/ci-doesnt-need-on-demand-moving-our-build-pipelines-to-spot-instances-6fff1cd92ba8?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/6fff1cd92ba8</guid>
            <dc:creator><![CDATA[Yuvraj Singh Singhel]]></dc:creator>
            <pubDate>Wed, 05 Aug 2026 15:07:35 GMT</pubDate>
            <atom:updated>2026-08-05T15:07:33.741Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CrLBptJzr_npxVClAmkYUQ.png" /></figure><p><em>Contributors: </em><a href="https://medium.com/u/f8e3522fe739"><em>Guptaanuj</em></a></p><p>CI/CD pipelines have always had a money-vs-stability problem. Run on-demand AWS instances and your build infrastructure is rock solid, expensive, and predictable. Run on spot instances and your costs drop 70–90%, but AWS can pull the rug with 2 minutes of warning.</p><p>For most teams, this is a false choice. Either pay full price for reliability, or save money and accept that builds will fail in ways nobody can debug.</p><p>At Razorpay, we stopped accepting that trade-off. We built a self-healing infrastructure layer for GitHub Actions on Kubernetes that runs <strong>80% of our CI workloads on spot instances</strong> while maintaining a <strong>99.2% job success rate</strong>. When AWS terminates a spot node mid-build, our system detects it, retries the job, cleans up the orphaned pods, and the developer never knows.</p><p>This is the story of what we built, why polling wasn’t an option, and the war stories that taught us how to do retries without burning everything down.</p><h3>The Problem With Spot Instances On CI</h3><p>Spot instances are AWS capacity that nobody else wants right now. The pricing is brutal compared to on-demand: a c5.2xlarge that costs around $0.34/hour on-demand drops to roughly $0.08/hour on spot. For workloads like CI/CD, where jobs are short-lived and parallelizable, the math is obvious.</p><p>The catch is in the contract. AWS reserves the right to take spot capacity back at any moment, with a 2-minute warning. That works for some workloads. For others, it’s catastrophic.</p><p>GitHub Actions runners on Kubernetes is the hard case. A typical CI job goes like this: GitHub assigns the job to a runner. The runner is a pod on a Kubernetes cluster running on an AWS Spot instance. The job downloads dependencies, runs tests, builds artifacts. The runner reports back to GitHub.</p><p>Now insert a spot termination at minute 4 of a 7-minute build. What happens?</p><ul><li>The runner pod dies mid-job.</li><li>GitHub never gets a completion signal; the job hangs until timeout, then marks as “failed”.</li><li>A new runner pod gets scheduled (assuming spot capacity is available).</li><li>The orphaned pod from the killed node sits in “Failed” state forever, consuming cluster slots.</li><li>The developer sees a red X with no useful explanation.</li></ul><p>Multiply that across 10,000+ GitHub Actions jobs per day across hundreds of repositories. The math turns ugly fast. Stale pods accumulate. Failed jobs require manual retries. Engineers stop trusting CI signals. Some teams give up and migrate back to on-demand, paying 4–5x more for a problem that was solvable.</p><p>That was the state we were in. Spot instances saved real money but extracted a real tax in operational toil and developer trust.</p><h3>What We Built</h3><p>The system is four components working as a pipeline, plus one that runs on the side. Each one has a single responsibility and is deliberately ignorant of the others.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fKmDvcrtPyrdK7hXjp2cUQ.png" /></figure><p><strong>spot-node-metric</strong> is the early warning system. A DaemonSet that runs on every node, tracking AWS Spot interruption notices in real-time. When a node receives the 2-minute warning, this metric fires, which lets the scheduler stop assigning new jobs to nodes that are about to disappear.</p><p><strong>spot-loss-checker</strong> is the classifier. When a job fails, this service decides whether it was a spot termination or a real failure. The detection is surprisingly simple: it scans the runner pod logs for the pattern √ Connected to GitHub. If that line is present, the runner had successfully registered before dying; the failure was a spot termination. If it’s absent, the runner never connects, which usually means a real configuration problem. This pattern catches 95%+ of actual terminations and tolerates transient network blips without false-flagging them.</p><p><strong>rerun-failed-jobs</strong> is the recovery engine. When spot-loss-checker marks a job as retriable, this service calls the GitHub API to rerun it. The retry tracks an idempotency key so the same job can’t be retried twice if duplicate webhooks fire. Exponential backoff prevents the system from hammering the GitHub API during large spot disruption events.</p><p><strong>rerun-failed-jobs</strong> is the recovery engine. When spot-loss-checker marks a job as retriable, this service calls the GitHub API to rerun it. The retry tracks an idempotency key so the same job can’t be retried twice if duplicate webhooks fire. Exponential backoff prevents the system from hammering the GitHub API during large spot disruption events.</p><p><strong>k8s-runner-cleanup</strong> is the housekeeper. Every 15 minutes, it scans for stale runner pods in the actions-runner-system namespace. It checks each pod’s logs for the same connection pattern. Pods that never established connection get deleted. This is what prevents the orphaned-pod accumulation problem.</p><p><strong>github-action-monitoring</strong> is the entry point. A Flask service that receives webhooks from GitHub every time a job changes state. It extracts metadata: workflow name, repository, runner ID, queue time, run time, conclusion. It exposes Prometheus metrics with histogram buckets so we can compute P50/P90/P95/P99 percentiles for every dimension we care about.</p><p>The pipeline is event-driven end to end. A job fails, a webhook fires, the classifier runs, the retry fires, all within seconds. No polling, no batching, no waiting for the next cron tick.</p><h3>The Four Design Decisions That Shaped This</h3><p>A few calls deserve a closer look because each one had a non-obvious second-best alternative we deliberately rejected.</p><h4>Webhooks over polling</h4><p>GitHub provides two ways to know about job state: poll their API on an interval, or receive webhooks when state changes. We chose webhooks. The trade-off looks small until you sit with it.</p><p>Polling is simpler operationally. It’s also a delay multiplier. A 30-second poll interval means the average detection latency for a failure is 15 seconds. Multiplied across 10,000 jobs/day and several stages of recovery (detect → classify → retry → restart), polling-based detection adds up to several minutes of recovery time per job. Webhooks fire within milliseconds of the state change.</p><p>The cost was operational complexity. Webhooks need a publicly reachable endpoint with proper auth, retry handling for failed deliveries, and idempotency for duplicate fires. None of these are hard problems individually. All of them are skipped in a polling implementation. We paid the complexity tax to buy real-time detection.</p><h4>Log pattern matching for spot detection</h4><p>The cleaner architectural answer for spot detection would be to receive AWS’s interruption notice events, correlate them with running jobs, and mark those jobs as spot-killed when the corresponding nodes disappear. We considered building that. We didn’t.</p><p>The simpler approach was scanning runner logs for the pattern √ Connected to GitHub. Present means the runner registered successfully and something killed it. Absent means the runner had a real problem.</p><p>This is brittle in a textbook sense. If GitHub changes the connection success log format, our detector breaks. We’ve already had that happen once during a GitHub Actions runner update. The cleanup pipeline silently stopped working for nearly a day before the metrics showed the divergence.</p><p>The trade-off was deliberate: spend two days writing a brittle but immediately useful detector, or spend two months building correlated event handling that handles every edge case. We took the short path knowing we’d pay maintenance costs later. The detector has been worth it.</p><p><strong>Detecting spot kills from runner logs</strong></p><p>When a spot node disappears, we need to tell two cases apart: a runner reclaimed by AWS versus a runner that hit a real failure. The clean architectural answer is to consume AWS’s interruption-notice events, correlate them with running jobs, and mark the jobs whose nodes vanish as spot-killed. We scoped that and chose to defer it.</p><p>Instead, the detector scans runner logs for the registration success line, √ Connected to GitHub. If a runner logged it and then died, a spot reclaim took it. If the line never appeared, the runner had a real problem. Two days of work against an estimated two months for full event correlation, for a signal we needed immediately.</p><p>The dependency this creates is narrow and explicit: one external log string. It sits off the job-execution path. If GitHub changes the format, jobs keep running and rescheduling normally; what degrades is the cleanup-and-attribution signal, not reliability. That bounded blast radius is what made the shortcut defensible.</p><p>We found the boundary the hard way. A GitHub Actions runner update changed the log format once, and the cleanup pipeline stopped attributing kills correctly. Metric divergence surfaced it, and we now alert on that divergence directly, so the detection window is minutes rather than the better part of a day. Full event correlation remains the planned evolution; until the maintenance cost of the log-scan outweighs its simplicity, the simple version keeps earning its place.</p><h4>Histograms over raw metric storage</h4><p>Early in the build, we stored every job’s queue time and run time as individual Prometheus samples. The cardinality exploded the moment we scaled past a few hundred jobs per minute. Memory pressure caused OOMKills in the monitoring service. The thing built to monitor reliability was the least reliable component in the stack.</p><p>The fix was switching to histogram buckets. Instead of storing every job’s exact queue time, we count how many jobs fell into each bucket: 0–100ms, 100–500ms, 500ms-1s, 1–5s, and so on. Server-side percentile calculation gives us P50/P90/P95/P99 from the histograms. Cardinality drops from “number of jobs” to “number of buckets,” which is constant.</p><p>The cost is precision. A job that took exactly 437ms gets bucketed as “100–500ms” rather than recorded exactly. For percentile analysis, this doesn’t matter. For debugging a specific failure, we use logs, not metrics. The split is the right one.</p><h4>Idempotency keys on every retry</h4><p>The first retry storm we hit taught us something. During a large spot disruption that killed several hundred jobs at once, the system fired retry webhooks for each one. Some webhooks hit our retry service twice because of GitHub’s at-least-once delivery semantics. The result: jobs ran twice, sometimes three times. Test suites that mutate shared state (database fixtures, integration test environments) corrupted themselves.</p><p>The fix was idempotency keys. Every retry attempt generates a deterministic key from the job ID and the original failure timestamp. The retry service tracks recent keys and refuses to fire a retry it’s already attempted. Duplicate webhooks get logged and dropped.</p><p>This sounds obvious in retrospect. Most production systems with retries need it. The lesson worth carrying: idempotency isn’t a defense against bugs in your code; it’s a defense against the at-least-once delivery semantics of every event system you’ll ever build on.</p><h3>The War Stories</h3><p>A few things broke in ways that taught us something.</p><p><strong>The retry storm of the first big spot event.</strong> A large AWS Spot capacity reclamation killed approximately 200 jobs simultaneously. Our retry service got 200 webhook deliveries in a 30-second window. It fired 200 GitHub API calls to trigger reruns. GitHub responded with rate limit headers, but the retry service didn’t honor them, so it kept retrying. The rate limit got worse. Some jobs were retried 4–5 times before the system stabilized.</p><p>The fix was exponential backoff with jitter on retry attempts, plus respecting GitHub’s rate limit headers. After this, the system gracefully degraded under load instead of amplifying the disruption.</p><p><strong>The double-retry bug from racing webhooks.</strong> Two webhook deliveries for the same failed job arrived within 200ms of each other (GitHub’s at-least-once semantics). Both made it past the deduplication check because the dedup window was 100ms. The job ran twice. The integration tests it ran had ordering dependencies; they corrupted shared state and failed in confusing ways for the next 6 hours.</p><p>The fix was the idempotency keys above, plus widening the dedup window to 30 seconds and using a proper distributed lock instead of an in-memory check.</p><p><strong>The silent cleanup failure.</strong> GitHub updated the runner binary, which changed the connection success log line. Our log pattern matcher stopped recognizing successful connections, which meant k8s-runner-cleanup started flagging every pod as stale. Within an hour, it had deleted every active runner pod in the cluster. CI ground to a halt. Recovery took ~45 minutes once we figured out what was happening.</p><p>The fix was adding a sanity check: if cleanup is about to delete more than 20% of active pods in a single run, abort and alert. Slow degradation gets caught. Catastrophic deletion gets prevented.</p><h3>What It Bought Us</h3><p>Six weeks of engineering effort produced an infrastructure layer that has been quietly running ever since. The numbers tell the practical story.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9N79kELzrWDEzcgTqiLIxg.png" /></figure><p>The cost story is the headline: 60% reduction in CI compute spend, with reliability that’s better than what we had before. The toil story is the more important one: zero manual retries required across 10,000+ jobs per day. The on-call alerts for CI failures have effectively disappeared. The system is in production org-wide.</p><p>The system is in production for five teams handling infrastructure, platform, and payments workloads.</p><h3>What’s Next</h3><p>The current detection logic treats all failures the same: if a runner disconnected unexpectedly, retry it. That’s correct most of the time, but it retries permanent failures alongside spot terminations, which wastes API calls and pollutes the metrics. We’re working on classifying failures as transient (network blip, spot termination, resource exhaustion) vs. permanent (bad test, missing dependency, syntax error). Transient failures retry; permanent failures route to the responsible team.</p><p>Cost attribution is the next obvious layer. The system knows which teams’ jobs ran on spot instances and which were rescued from spot terminations. Exposing that as per-team cost savings makes the platform’s value visible to leadership. Right now the savings are real but invisible to the people whose budgets they affect.</p><p>Multi-cluster support is the long-term direction. Today the system runs in a single Kubernetes cluster. As Razorpay’s CI footprint grows, a centralized control plane that spans clusters would simplify operations and improve resource utilization across the org.</p><h3>What We’d Say To Other Teams Building This</h3><p>Three lessons that aren’t specific to GitHub Actions on Kubernetes.</p><p><strong>Webhooks beat polling for anything event-driven, even though webhooks cost more to build.</strong> The latency difference compounds across stages. A pipeline that’s polling at every step accumulates seconds of delay per job. A webhook-driven pipeline reacts instantly. Pay the auth and idempotency tax upfront; reap the recovery time benefits forever.</p><p><strong>Brittle detectors that work today beat elegant detectors that ship in six months.</strong> Our log-pattern matcher will break when GitHub changes their log format again, and we’ll fix it again. That maintenance cost is much smaller than the cost of waiting for the perfect event-correlation system. Ship the version that works now and budget for the inevitable maintenance.</p><p><strong>Idempotency is not optional in production retry logic.</strong> Every event system you’ll ever build on has at-least-once delivery semantics. Every webhook fires more than once eventually. Every retry path will be invoked twice in some sequence of events. Build for that on day one. Don’t wait for the second incident to teach you.</p><p>The deeper takeaway is that spot instances aren’t risky infrastructure. They’re predictable infrastructure with one specific failure mode. Once you’ve built a system that handles that failure mode automatically, the cost savings are nearly free. The trade-off between reliability and cost wasn’t fundamental. It was just under-engineered.</p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6fff1cd92ba8" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/ci-doesnt-need-on-demand-moving-our-build-pipelines-to-spot-instances-6fff1cd92ba8">CI Doesn’t Need On-Demand: Moving Our Build Pipelines to Spot Instances</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How We Refresh Razorpay's Data Warehouse 10x Faster with Graphs and Indexes]]></title>
            <link>https://engineering.razorpay.com/how-we-refresh-razorpays-data-warehouse-10x-faster-with-graphs-and-indexes-538abc244703?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/538abc244703</guid>
            <dc:creator><![CDATA[Amit Prabhu]]></dc:creator>
            <pubDate>Tue, 14 Jul 2026 14:06:16 GMT</pubDate>
            <atom:updated>2026-07-14T14:06:15.539Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*malmruEkb8MF1xaxpfBmOw.png" /></figure><p>Contributors: <a href="https://medium.com/u/2bf56caf1392">Utkarsh Koppikar</a> <a href="https://medium.com/u/48e3269124e2">Rohan</a></p><h3>Background</h3><p><strong>Razorpay</strong> provides the payment infrastructure for millions of merchants globally. Behind every payment, settlement, and refund is a microservices architecture where each service owns its own database. While this keeps services independent and scalable, it creates a challenge for stakeholders who need to see across those boundaries.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zBXNSlVZDRtcF2LZZGFD3g.png" /></figure><p>The <strong>Data Platform</strong> team manages the infrastructure that bridges this gap. Transactional data flows into the lake via CDC pipelines, ingested onto S3 in Delta Lake, Apache Iceberg, or plain Parquet formats. On top of the lake, we build domain-specific warehouse tables — wide, pre-joined tables that co-locate all the data a consumer needs, queryable via Trino. These power two use cases: <strong>Analytics</strong> (internal dashboards on Tableau and Superset) and <strong>Reporting</strong> (merchants and regulated entities who download structured data exports; Razorpay generates nearly a million such reports per month).</p><p>The warehouse tables that power both use cases are called <strong>Facts</strong>. A <strong>Fact</strong> is a flat denormalised table on S3, produced by joining 10 to 30 microservice tables and materialising the result once. A settlement Fact, for example, merges payments, refunds, adjustments, and card details into a single wide row so that a dashboard or report reads from a single table instead of joining across services in real time. It is closer to a domain-specific materialised view than a classical data warehouse fact table. We maintain over 50 such Facts, and approximately 40% of all merchant reports are served directly from them.</p><p>As data volumes and the number of entities per fact grew, the batch generation pipeline began to show its limits, prompting us to rethink the refresh strategy, the data layout, and how to handle high-cardinality dimensions. The rest of this post covers that journey.</p><h3>The Full Refresh Pipeline: Our Baseline and the Pain</h3><p>The original full-refresh pipeline was straightforward.</p><ol><li><strong>Schedule: </strong>Airflow schedules Spark jobs on EMR daily during off-peak hours, passing a Fact Config from S3—a configuration file listing the tables to join, columns to select, join conditions, and the output format.</li><li><strong>Full Refresh</strong>: The job then reads all source tables from the data lake, performs a full Spark join, and overwrites the entire denormalised table back to S3.</li><li><strong>Notify</strong>: Once complete, it publishes the max(primary_table_updated_at) timestamp to the Reporting Service, which uses it to split queries: the Fact covers the historical window, and TiDB — our warm store, a distributed MySQL-compatible database holding recent transactional data; fills the freshness gap for data not yet materialised into the Fact.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zXA7MRtQFngIMeXwe4OQpQ.png" /></figure><p>This approach created three classes of problems:</p><ol><li><strong>Operational</strong>: Pipelines ran on spot nodes for many hours, making spot loss inevitable. Since the business logic required the full dataset at once, there was no safe checkpoint — a single failure meant restarting from scratch. We eventually chained smaller facts together to create checkpoints.</li><li><strong>Scaling</strong>: Our data has back-dated references — a payment today can reference an order from five years ago, a refund can reference a year-old payment. This forces unbounded joins and full table scans on every secondary table with no safe time window to apply. The Payments fact grew to join over 30 tables and ran for 8–10 hours. Facts initially contained data from 2018, and to manage cost, we reduced fact retention to one year as a stopgap, allowing some reports to fall back to warm stores for older data — but this deepened our TiDB dependency and blocked retention initiatives on the warm store.</li><li><strong>Cost</strong>: Pipelines ran for 70+ hours per day across large EMR clusters. We moved most facts to alternate-day schedules, creating up to 48-hour freshness lags.</li></ol><h3>Previous Attempts and Challenges: The Path to Solution</h3><p>We attacked this problem from both short-term and long-term perspectives. Fact decomposition, revised data retention, alternative-source fallbacks, and alternate-day scheduling served as short-term solutions to maintain stability without affecting the user experience.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*E7K6zH_NUtfYnjblWLAgiw.png" /></figure><p>To solve these problems in the long term, we built a streaming pipeline that subscribes to Kafka CDC topics, models the Fact config as a dependency graph, and performs TiDB lookups to produce denormalised rows in near real time. (Details: <a href="https://engineering.razorpay.com/real-time-denormalized-data-streaming-platform-part-1-9f3c730dd9c6">Real-time Denormalised Data Streaming Platform</a>)</p><p>The solution worked for a year but eventually failed for three primary reasons:</p><ol><li><strong>Highly mutable data</strong>: 10–20 change streams joined at 10M+ events per 30-min window drove costs unsustainably high.</li><li><strong>All data in warm store</strong>: Back-dated references forced us to keep all historical data in TiDB, blocking any retention initiative.</li><li><strong>Write amplification</strong>: High-cardinality joins amplified upsert volumes. Since joins were slow on the lake, we had no option but to force all join tables into precomputed facts.</li></ol><h3>The Pivot: Incremental Facts</h3><p><em>The core insight: treat Facts as incrementally maintainable graphs. Instead of regenerating the full table, process only the change events for each entity using the dependency graph to know which related rows to fetch, and a secondary index to know where on the lake to find them.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u3OWt0a3FXfBcAcw5CdZyg.png" /></figure><p>Consider a real-world payment creation flow, you first create an <strong><em>Order</em></strong> for the product you want to buy, and a <strong>Payment </strong>is made on that order using a <strong>Card </strong>by applying a <strong>Discount </strong>allowed by the ongoing <strong>Offer. </strong>This relationship can be visualised as a graph as shown in the image and this became one of the core insights for the approach.</p><p>Before we into the low level details, we need to understand the following constraints:</p><ol><li><strong>Data Partitioning</strong>: Our data lake on S3 contains source tables in open-source Delta format and partitioned on `<strong>created_date</strong>`.</li><li><strong>Data Indexing: </strong>We use Z-ordering on the primary key and merchant_id columns. Since these columns have high cardinality, this setup is inefficient for lookup queries.</li></ol><p>Three design questions had to be resolved:</p><ol><li><strong>How do you fetch all updates for an entity on T-1 day?</strong></li></ol><p>We built a silver-layer pipeline that creates flattened, deduplicated tables partitioned by updated_date, retaining only the latest change per primary key per day.</p><p><strong>2. How do you know which related rows to fetch for a change event?</strong></p><p>We modelled the Fact config as a traversable in-memory dependency graph, with edges derived from join conditions. Consider a fact that joins five tables:</p><pre>SELECT P.*, O.*, C.*, D.*, O.* <br>FROM payments P <br>LEFT JOIN orders O ON O.id = P.order_id    -- edge: Payments → Orders <br>LEFT JOIN cards  C ON C.id = P.card_id     -- edge: Payments → Cards <br>LEFT JOIN discounts D on D.payment_id = P.id -- edge: Payments → Discounts <br>LEFT JOIN offers O on O.id = D.offer_id  -- edge: Discounts → Offers</pre><p>This produces the graph as shown above. Each edge carries the join predicate as metadata.</p><p>The graph tells us exactly what to fetch for any change event:</p><ul><li><strong>Update on Payments (P1)</strong>: follow edges outward — look up Orders where orders.id = P1.order_id, and Cards where cards.id = P1.card_id. Combine to form a complete denormalised row.</li><li><strong>Update on Orders (O1)</strong>: back-traverse to the root — look up Payments where payments.order_id = O1.id to find the primary keys affected. Cards are not in the path from Orders to the root, so it is skipped.</li></ul><p><strong>3. How do you look up data efficiently on the lake?</strong></p><p>We built a Secondary Index per entity , a small table holding join columns and <strong>created_date</strong> partition values. For example, a payment index table would include id, card_id, order_id, and created_date.</p><p>Because this table has a small sub-set of columns that change infrequently,it is significantly smaller and easier to maintain than the original source table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kj5a7cKgbLmkRFfISp69Fw.png" /></figure><p>The index narrows reads to a handful of partitions, eliminating full table scans. An independent batch pipeline upserts the Secondary Index from the silver layer. This is the core component that removed our dependency on TiDB for historical lookups.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g2rOPyWerAj82iweVBMZvA.png" /></figure><h3>Incremental Processing using Graph Traversals</h3><p>When a warehouse job is triggered, it parses the Fact configuration to create a dependency graph in memory. It processes updates for each node sequentially, starting from the primary node. Processing logic differs between the primary table and secondary tables. We can therefore broadly classify processing into two phases: the <strong>Primary flow</strong> and the <strong>Secondary flow</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_lG8Ru6xnKVpNpIweg6Cfg.png" /></figure><h3>The Primary Flow:</h3><p>In this flow, we process only the primary table in the graph (payments, in the example above).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6lpxfa0fakCO2baQVQdkmw.png" /></figure><p>For example:</p><ol><li><strong>Fetch</strong> primary table updates from the last checkpoint.</li><li><strong>Join</strong> updates with each secondary index to get created_date partition values; use those to read only the relevant partitions of each source table.</li><li><strong>Stitch</strong> all data into denormalised rows and <strong>upsert</strong> into the target fact, scoping the merge to affected partitions only.</li></ol><h3>The Secondary Flow</h3><p>In this flow, we iterate through all secondary tables in the graph using a level-order traversal and process them sequentially.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_r1fCEIfktiOzp4nf8VvHg.png" /></figure><ol><li><strong>Fetch updates</strong> for the secondary table from the last checkpoint. (e.g offers)</li><li><strong>Back-traverse the graph</strong>: Join with the indexes of all ancestor nodes up to the root (e.g., discounts_index, payments_index for Offers) to enrich each update row with the primary key and partition values needed for the target merge. We don’t need a full source table read, since the required ancestor data is already in their indexes.</li><li><strong>Merge into Fact</strong>: Upsert the partially denormalised rows to the target fact, scoped to affected partitions.</li></ol><h3>Handling High Cardinality and Dimension Tables</h3><p>High-cardinality dimension tables are expensive to denormalise due to write amplification: a single update can fan out to thousands of fact-row rewrites.</p><p>For example, consider a &quot;Payment Links&quot; table in which a single link can accept thousands of payments. If Payment Links is a secondary table and a column is updated, thousands of payments in the Fact table must be updated to reflect that single change. <strong>Hence, not every join belongs in a precomputed fact.</strong></p><p>However, joins on our lake are expensive too because our domain doesn’t support time-bounded joins, which requires a full table scan on secondary tables. We wanted to push down a common predicate to reduce scans on the secondary tables.</p><p>For example, if you are joining payment_links and orders:</p><pre>SELECT *<br>FROM payment_linksLEFT <br>JOIN orders on orders.id = payment_links.order_id<br>WHERE payment_links.merchant_id = m1 and payment_link.created_at between t1 and t2</pre><p>If we run this query on Trino, it will push down filters (merchant_id, created_at) for payment_links, but will result in a full table scan for orders.</p><p>Report generation is usually scoped by merchant_id, which is present in most tables. We decided to use it as a common predicate. Since merchant_id has high cardinality, we cannot partition data on it; instead, we use bucketing on merchant_id with a fixed number of buckets, similar to sharding. We chose Iceberg (V2) as the table format for its native bucketing support.</p><p>Adding merchant_id to both sides of the join condition lets Trino push predicate on both the sides, scanning only the matching bucket:</p><pre>LEFT JOIN orders on orders.merchant_id = payment_links.merchant_id <br>AND orders.id = payment_links.order_id<br>WHERE payment_links.merchant_id = m1 and payment_link.created_at between t1 and t2</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/990/1*er2Yn0Rko-e3f19RMpknWw.png" /></figure><p>This unlocked joins in the data lake. We addressed the remaining gaps by building an Iceberg replication pipeline and tuning non-Fact tables with sort orders and `merchant_id` bucketing. We also used hidden month/day partitioning to reduce merge scans. Finally, we added a query builder in Reporting Service to route joins intelligently. Some joins are served from the precomputed Fact table, while others run between Fact and Iceberg tables at query time.</p><h3>Production Rollout</h3><p>Rolling out to production was more challenging than anticipated. We needed to ensure that no inaccurate data reached our merchants.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oZdY1iGO3QkEtbAseD3Vgg.png" /></figure><p>We followed three phases to ensure accuracy before decommissioning the legacy flow:</p><ol><li><strong>Data reconciliation</strong>: Compared incremental and full-refresh facts record-by-record, resolving checkpointing, out-of-order events, and index lag issues until we hit 100% accuracy.</li><li><strong>Shadow testing</strong>: Ran A/B experiments generating shadow reports from incremental facts in parallel with live reports. Automated nightly comparisons flagged mismatches; we iterated until both were identical.</li><li><strong>Gradual live rollout</strong>: Progressively shifted live traffic to incremental facts with a fallback to the legacy flow, addressing edge cases without merchant impact.</li></ol><p>Moving to a three-pipeline architecture from a single full refresh pipeline naturally raises the chance of a hiccup here and there, so we’ve made the system self-healing. Each pipeline now publishes its own checkpoints, meaning downstream pipelines can see exactly where their upstream counterparts are at and intelligently decide how much data to process if they’re lagging. If we do hit a delay, we automatically fall back to sources like TiDB to keep data fresh in reports. We also have Data Quality checks watching the whole flow; when they spot a mismatch, they flag it on our slack channels, narrowing down the debug scope.</p><h3>Impact</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tJDg3XGxQ0WiRySgoujvzg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uKgTH1xs2kGstk45Ew7hGQ.png" /></figure><p>We migrated the five largest facts to the incremental strategy and the impact numbers are shown below:</p><p>The ~90% reduction in compute time translated to proportional cost savings. Restoring full historical coverage in Facts allowed us to reduce TiDB retention significantly. Runtime joins enabled decomposing large monolithic facts into smaller, domain-specific ones — reducing blast radius and maintenance cost.</p><h3>Lessons Learned</h3><p>1. <strong>Streaming isn’t always the answer: </strong>State-heavy joins at high event volumes eventually become cost-prohibitive. Batch + incremental can win.</p><p>2. <strong>The lake should be queryable, not just archival: </strong>Secondary indexes on the lake eliminated warm-store dependency for historical lookups. Iceberg is adding<a href="https://www.linkedin.com/posts/dipankar-mazumdar_parquet-dataengineering-softwareengineering-activity-7451810741856325632-YaIQ/"> native support for secondary indexes</a> in upcoming releases.</p><p>3. <strong>Not every dimension belongs in a materialised fact: </strong>Runtime joins with bucketed Iceberg tables are cheaper than write amplification at scale.</p><p>4. <strong>Facts are graphs, not tables: </strong>Modelling fact configs as traversable dependency graphs is what unlocks incremental maintenance.</p><h3>Future Roadmap</h3><p>We’ve seen incredible operational and cost improvements from migrating our first five Facts, and it’s clear that the Incremental Strategy is the right path forward. We’re excited to transition the rest of our facts onto this new approach. Alongside this, we’re also diving into these key initiatives to push things even further:</p><ol><li><strong>Real-time secondary index</strong>: We are exploring Apache Flink to merge the index pipeline into CDC ingestion. This approach achieves near-real-time freshness and reduces latency for lookups and ad-hoc queries.</li><li><strong>Extend support to more Sinks</strong>: Applying incremental processing to unlock savings and near-real-time freshness for warehouse tables in other destinations like ClickHouse, TiFlash, etc.</li></ol><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=538abc244703" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/how-we-refresh-razorpays-data-warehouse-10x-faster-with-graphs-and-indexes-538abc244703">How We Refresh Razorpay&#39;s Data Warehouse 10x Faster with Graphs and Indexes</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running Hermes at Razorpay: a Network-isolated, Self-improving “Second Brain” for every Employee]]></title>
            <link>https://engineering.razorpay.com/running-hermes-at-razorpay-a-network-isolated-self-improving-second-brain-for-every-employee-f91d56bea3f1?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/f91d56bea3f1</guid>
            <dc:creator><![CDATA[ashwath kumar]]></dc:creator>
            <pubDate>Sun, 12 Jul 2026 14:22:53 GMT</pubDate>
            <atom:updated>2026-07-22T10:47:59.946Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How we run a personal AI agent for everyone at Razorpay: always on, multi-model, and safe by construction.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HqDYAzUS8WutF4h5dS2SQg.png" /></figure><p><em>Contributors: </em><a href="https://medium.com/u/3da4af9fecce"><em>Siddharth Tripathi</em></a></p><p>Today, more than <strong>220 Razorpay employees each have their own always-on AI agent.</strong> On a typical day, about 84 of them are actively working.</p><p>Every one runs in its own isolated Kubernetes namespace, with its own encrypted storage, its own cloud identity, and its own network policy. It learns new skills as its owner works, and it keeps running long after they close their laptop: one employee’s agent has already logged <strong>more than 15,000 sessions, 90% of them while its owner was asleep or away</strong>.</p><p>Provisioning a new one takes <strong>under two minutes</strong>.</p><p>Running the agents was never the hard part. Running them <em>safely</em> was: 220 of them, each with shell access and live credentials, without any single agent becoming a path into another employee’s data or out to the open internet. The answer came down to one design choice, and everything in this post is a consequence of it:</p><p><strong>Isolation is a property of the infrastructure, not the application.</strong></p><p><em>Hermes itself is an </em><a href="https://github.com/nousresearch/hermes-agent"><em>open-source agent by Nous Research</em></a><em>; what we built is the platform that runs it safely &amp; isolated, for the whole company.</em></p><h3>Proof It’s Real: One Instance, Eight Weeks In</h3><p>Before any of the architecture, here’s the proof that people actually use this. The following is one real instance from our cluster, over its first eight weeks (numbers pulled live, the person anonymised).</p><p>In <strong>eight weeks</strong>, this one user’s Hermes ran <strong>15,039 sessions</strong>. Only <strong>391</strong> of those were the person sitting down to chat with it; the other <strong>13,570 were autonomous</strong> runs the agent kicked off on its own schedule while its owner was asleep or in meetings. That ratio is the whole idea in one statistic: the assistant does most of its work when you’re not there.</p><p>What is it doing in those runs? They’d wired up <strong>21 always-on scheduled jobs</strong> that turn Hermes into a personal intelligence service:</p><ul><li><strong>Ingest.</strong> Every hour it pulls from <strong>23 Slack channels</strong> (plus DMs), <strong>6 GitHub repos</strong>, and Google Workspace (calendar, Gmail, Drive, Docs); on a daily cadence it sweeps <strong>62 Twitter/X handles</strong>, market RSS feeds, and a set of Substacks.</li><li><strong>Synthesise.</strong> A pipeline compiles all of that raw material into a <strong>personal wiki</strong> (pages for people, projects, decisions, threads, and concepts), then extracts an <strong>entity graph</strong> and builds a <strong>vector index</strong> (Titan-v2 embeddings) so the agent can answer questions across everything it has read.</li><li><strong>Deliver.</strong> It posts a <strong>07:15 morning digest</strong> and a <strong>19:00 end-of-day digest</strong> to a private Slack channel, follows the morning digest with a synthesised news thread, and nudges a running <strong>task tracker every two hours</strong>.</li></ul><p>On top of the automation, the instance has taught itself <strong>98 skills across 25 categories</strong>, a “skill” being a reusable, written procedure the agent can follow (Part 1 shows how they’re made): from github-pr-workflow, systematic-debugging, and test-driven-development on the engineering side to self-authored, Razorpay-specific ones like an “AI-playbook gap-finder” and a “wiki brain.” None of these were written by a platform admin; the agent distilled them from repeated work, and none of them leak out of this one person’s sandbox.</p><p>That’s a single tenant, and an outlier in ambition, not in kind. The same primitives (a persistent pod, scheduled runs, private skills, audited egress) sit under all 220 instances, waiting for each person to lean on them as hard.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/924/1*LMoALWe0VBHVq0Q7zsxJDg.png" /><figcaption><strong>Figure 1: Architecture overview</strong></figcaption></figure><h3>Part 1: Why We Built This, and What You Get</h3><h3>The Problem</h3><p>We wanted to give everyone at Razorpay their own “second brain”: a personal, always-on assistant that holds their working context and helps them get things done. Handing everyone a coding agent to run on their laptop was the obvious starting point, but for our use case it fell short on three counts.</p><p><strong>First, a laptop is only awake when its owner is.</strong> It sleeps on the commute and stays shut over the weekend; the VPN drops when the lid closes; cloud credentials expire overnight; and nothing the agent does on it leaves a trail a security team can see. We wanted an assistant that keeps working when the person doesn’t, somewhere always-on and observable, which rules the laptop out on both counts.</p><p><strong>Second, we wanted to be multi-model.</strong> Not every task deserves the most expensive model. We wanted to route routine work to a cheaper or open-source model and reserve a stronger one for when it’s worth it, rather than being pinned to a single model or provider.</p><p><strong>Third, we wanted skills that learn:</strong> an assistant that gets better at <em>your</em> job over time, not a generic one.</p><p>And because we’re a fintech, none of this could come at the cost of safety. A tool that holds each person’s working context must not leak one employee’s data into another’s, be reachable from the open internet, or become a path for exfiltration or a poisoned dependency.</p><p>A shared, multi-tenant <em>application</em> would put all of that trust in application-layer checks getting it right every single time. That wasn’t a bet we were willing to make. So we did something different: <strong>we made isolation a property of the infrastructure, not the app.</strong></p><h3>How We Solved It</h3><p>Our implementation of Hermes resides within an always-on Kubernetes cluster, where <strong>every employee is provisioned their own personal container</strong>, <em>isolation becomes a fundamental property of the architecture</em> rather than a fragile policy. This ensures that every assistant persists in its work, operating autonomously whether the owner is at their desk or offline.</p><p>Access is mediated by a centralized gateway where users authenticate via Google. Upon signing in, they are programmatically routed to their specific, isolated environment. We have effectively pushed <strong>isolation to the container boundary</strong> and identity verification to the SSO layer, ensuring that ownership of each “brain” is enforced at the network level. Furthermore, the environment is hardened through total network isolation, remaining accessible only via the internal corporate network and <em>shielded from the open internet</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/688/1*YjcpQCl2fNm7li8OVm-QLA.png" /><figcaption><strong>Figure 2: End-to-end architecture.</strong></figcaption></figure><p>The platform is deliberately <strong>agent-runtime-agnostic</strong>. The same identity, isolation, and auditing that host Hermes today could host a different agent, or any future tool that speaks the same protocols, tomorrow, each as just another tenant. A security team can stand up a new runtime as a template, a namespace, and a cloud-identity role, without rebuilding the platform.</p><h3>What You Actually Get</h3><p>From a person’s side it’s simple: open a URL, sign in with Google, and you’re in your own assistant. Three things make it more than a chat box.</p><p><strong>It’s always on.</strong> Because your assistant lives in the cluster, not on your laptop, it keeps its context and keeps working when you’re offline, which is what makes the next part possible.</p><p><strong>It’s multi-model.</strong> Routine work can go to a cheaper or open-source model; the hard problems can go to a stronger one. You’re not locked to one model or one vendor, and the assistant can switch per task.</p><p><strong>It learns your work.</strong> This is the headline, so it’s worth making concrete.</p><p>A “skill” is a reusable, written procedure the assistant can follow. Normally a human writes them. Hermes flips that: because your assistant is persistent, it watches <em>how</em> you get a recurring task done and turns that into a skill scoped to you.</p><p><strong>Before.</strong> You manually guide your assistant through a recurring report — pulling data, comparing stats, and formatting the output — each week.</p><p><strong>After.</strong> Hermes recognizes the pattern, codifying it into a reusable “weekly report” skill. It automates the task, refining its performance with each subsequent run.</p><p>Skill creation is autonomous by default: the agent notices a non-trivial workflow and saves it for reuse without being asked. The “weekly report” above is one shape it takes; others we’ve seen in the wild include an agent that learned to triage its owner’s on-call alerts, one that drafts release notes from merged pull requests in a team’s house style, and one that maintains a running wiki of everything its owner reads. Because these are procedures the agent writes <em>for itself</em>, the platform can optionally put every skill write behind a human approval gate, the same approve/deny flow we use for risky commands, so in a locked-down deployment nothing the agent teaches itself lands unreviewed.</p><p>Two earlier choices pay off here. Because skills live inside your personal container, what the assistant learns about your job never leaks into a colleague’s; the same isolation that protects your data keeps your skills yours. And because the cluster is always on, the assistant can do this distilling in the background, so you come back to a sharper assistant rather than a cold one.</p><h3>The Real Point: Safe Path has to be the Easy Path</h3><p>There’s a strategic idea underneath all of this. When security offers no sanctioned alternative, every “can I install X?” request either gets denied (driving the tool underground) or approved on faith. Neither scales.</p><p>Our answer was to make the sanctioned path genuinely <em>faster and easier</em> than running an agent on a laptop: one command provisions a fully isolated, audited, always-on assistant in under two minutes. Once the safe path is the easy path, shadow AI stops being a policy problem you police and becomes a default that people simply choose. That, more than any single control below, is the thing we’d want another team to take away.</p><h3>Part 2: How It Works</h3><h3>Isolation is Infrastructure, Not Application</h3><p>That one sentence is the spine of the whole design, so it’s worth being precise about what it means.</p><p>A shared, multi-tenant application would put every safety guarantee in application code: the app would be responsible for checking, on every single request, that user A can’t see user B’s data. Get one check wrong, once, and the boundary is gone. For a tool with shell access and live cloud credentials, that is too much to trust to code.</p><p>So we don’t. Each person gets their own pod, their own namespace, their own encrypted volume, and their own cloud identity, and the boundary between them is enforced by Kubernetes and the network, not by the application. A compromised agent doesn’t reach another employee because there is no application code path from one tenant to another to get wrong; the isolation is structural.</p><p>Everything below is a consequence of taking that seriously. We organised it as four security planes.</p><h3>The Four Security Planes</h3><p>Each plane is independently defensible; together they’re defense in depth.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UnVIA_drqDuJxD5XBpL8Mg.png" /><figcaption><strong>Figure 3: Defense in depth, the four security planes.</strong></figcaption></figure><h3>Plane 1. Identity: Only Ever Your Own Agent</h3><p><strong>Threat:</strong> a user must only ever reach their own agent, and nobody should be able to hand-edit a request into someone else’s.</p><p><strong>Design:</strong> one gateway, Google login, and a rule that maps the signed-in identity to exactly one pod and refuses everything else.</p><p><strong>Result:</strong> reaching another person’s agent would mean forging a signed session, not just changing a URL.</p><p>There is one way in: a gateway that every request passes through. You sign in once with Google, and from then on the gateway does two things on every request: it confirms who you are, and it checks that the agent you are asking for is <em>yours</em>. If the identity in your session does not match the agent in the URL, the request is refused. That check runs in the gateway on every request, against a session whose integrity is cryptographic (not a per-app config rule someone can fat-finger), which closes off the confused-deputy bugs common in shared AI platforms.</p><p>The gateway itself is locked down: no shell, non-root, read-only filesystem, minimal privileges, and spread across availability zones so losing a node never takes it offline.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_Z7zhPih0kkST4W9bpkJhA.png" /><figcaption><strong>Figure 4: Identity flow.</strong></figcaption></figure><p><strong>Under the hood,</strong> the gateway is a small Go proxy. Login is Google Workspace OIDC with PKCE (which binds the login handshake to the client that started it); on callback it verifies the token’s signature, issuer, hosted-domain and verified-email claims, canonicalises the email to a username, and mints an AES-256-GCM session cookie with authenticated metadata that prevents replay or downgrade. Every later request checks that the session’s user matches the requested subdomain before proxying.</p><h3>Plane 2. Isolation: One Pod Per Person</h3><p><strong>Threat:</strong> a compromised agent shouldn’t be able to touch another employee.</p><p><strong>Design:</strong> one pod, one namespace, one cloud identity, one encrypted volume per person. Nothing shared.</p><p><strong>Result:</strong> the maximum blast radius is a single employee, and you can delete it with one command.</p><p>Each person gets a dedicated Kubernetes namespace with their own pod, their own encrypted volume, and their own cloud identity. No two users share a process, a filesystem, a model server, or a credential cache. If an agent is compromised (a malicious image, or a successful prompt-injection), the damage is bounded to that one namespace, and tearing it down is a single kubectl delete namespace. This is the same property that makes it safe to try <em>untrusted</em> agent runtimes at all.</p><p>Inside the pod, Hermes runs as an init container plus three main containers:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0P2CQNk7Ag4BXA905fT4xQ.png" /><figcaption><strong>Figure 4: Per-tenant pod anatomy.</strong></figcaption></figure><ul><li><strong>Gateway:</strong> the AI engine (inference, tool execution, conversation state).</li><li><strong>Dashboard:</strong> the web chat UI, talking to the gateway over localhost.</li><li><strong>Socat loopback relay:</strong> a small sidecar (the “golden hacks” section explains why).</li><li><strong>Init: seed-config:</strong> seeds the config on first boot and preserves the user’s customisations after. Cattle, not pets.</li></ul><p><strong>Under the hood.</strong> Cloud credentials come from IRSA (IAM Roles for Service Accounts): the pod is handed short-lived, auto-rotated AWS credentials tied to its Kubernetes identity, so there are no long-lived API keys sitting in any pod.</p><h3>Plane 3. Network: The Only Way Out is The Audited One</h3><p><strong>Threat:</strong> a compromised agent shouldn’t be able to phone home, exfiltrate, or reach another tenant.</p><p><strong>Design:</strong> kernel-level network policy that allows egress only to the audit proxy and DNS, and denies traffic between tenants by default.</p><p><strong>Result:</strong> the sole route to the internet is the audited path, and lateral movement is dropped in the kernel.</p><p>Each tenant’s namespace gets a network policy that permits exactly two destinations: the in-cluster audit proxy, and DNS. Everything else is dropped. Between tenants, the default is deny, so a compromised agent can’t scan or reach its neighbours. Because the enforcement happens in the Linux kernel rather than in the application, an agent can’t opt out of it by being clever. New policies roll out in <strong>observe-then-enforce</strong> mode, logging would-be violations before they actually drop, so we never break legitimate traffic on day one.</p><p><strong>Under the hood.</strong> Networking runs on Cilium in eBPF mode, which replaces kube-proxy and matches traffic against Kubernetes identities (namespace, labels, service account) instead of IPs that churn on every restart, so policies don’t silently break when pods reschedule. Drops happen in-kernel and are observable via Hubble flow logs.</p><h3>Plane 4. Observability: Every Request Screened and Attributed</h3><p><strong>Threat:</strong> if an agent does try to misbehave, we have to see it and stop it in real time.</p><p><strong>Design:</strong> route every outbound call through one proxy that screens it against a live LLM policy and records it per user.</p><p><strong>Result:</strong> ~15M calls a month screened, ~219k blocked as they happen, every one tied to a named person.</p><p>Every call out of every agent goes through <a href="https://www.brex.com/crabtrap"><strong>CrabTrap</strong></a>, an open-source TLS-intercepting egress proxy (from Brex); the network plane guarantees pods can’t bypass it. For each call, CrabTrap does three things: it attributes the call to a specific person via a per-user token, records it to an encrypted, access-controlled audit store, and screens it against a <strong>live LLM-based policy</strong> that decides, in real time, whether the request is allowed to leave. The audit trail and the enforcement run on the same inline path, so we get a complete record <em>and</em> real-time blocking at once. Because the policy is data, not code, we can tighten it per tenant without redeploying a single agent pod.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l7tR8CNhrSUS3I8DWxX2oQ.png" /><figcaption><strong>Figure 6: Egress flow, every call screened, audited, then allowed or blocked.</strong></figcaption></figure><p>The scale is what makes it meaningful. Over a recent 30-day window:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ik8pwKOpyVwzvlyrGObS6A.png" /></figure><p>Every one of those calls is tied to a named person, not an IP, which is exactly what you need when the thing making the call is an autonomous agent.</p><p><strong>Under the hood.</strong> “TLS-intercepting” means CrabTrap re-signs upstream certificates with its own certificate authority so it can see inside HTTPS. That only works if <em>every</em> TLS client in the pod trusts that CA, which turned out to be one of the four “golden hacks” below. Repeat identical checks are cached (hundreds of thousands of decisions reused), so screening adds negligible latency.</p><h3>Multi-model Routing</h3><p>Not every task deserves the most expensive model. Inference is routed through Razorpay’s internal <strong>LLM Gateway</strong>, a single endpoint that fronts a roster of models across providers (Claude, GPT, Qwen, Kimi, GLM, DeepSeek, and more) behind one scoped key per agent.</p><p>Routing is per task, and mostly the agent’s own call. It runs on a cheaper, often open-source model by default, and reaches for a stronger one when a task actually needs the reasoning: a long refactor, a gnarly debug, a piece of analysis where a weak model would quietly get it wrong. Because the Gateway exposes the whole catalogue, the agent can discover what’s available and switch mid-session without any config change or redeploy, and a model that’s added centrally is instantly available to all 220 agents. AWS Bedrock (keyless, via IRSA) sits alongside as a first-class path for teams that want to stay on Anthropic models specifically.</p><p>The point of the indirection is that the choice of model is a <em>routing</em> decision, not a deployment one, and it’s a decision we can see: whichever model a call targets, it still flows through the audit proxy in Plane 4, attributed to a person.</p><h3>The Four Golden Hacks</h3><p><strong>These four bugs each took us hours to debug.</strong> Getting a hardened, off-the-shelf agent image to actually <em>run</em> inside a locked-down Kubernetes environment (with short-lived cloud credentials, TLS inspection, and network-isolated routing) turned up four non-obvious, undocumented workarounds, each a real production failure mode with an error that pointed nowhere near the cause. They are the most reusable thing we learned.</p><ul><li><strong>Cloud-credential token permissions via a supplementary group.</strong> The pod-identity webhook mounts the IRSA credential token owned by root, group-readable only. The agent’s privilege-drop step resets its supplementary groups, dropping the one that could read the token, so the runtime user can’t read its own credentials, and every model call fails with “permission denied.” The fix is to add the runtime user to that group <em>before</em> the privilege drop:</li></ul><pre># let the unprivileged runtime user read its own IRSA credential token<br>usermod -aG &quot;$(stat -c &#39;%g&#39; &quot;$AWS_WEB_IDENTITY_TOKEN_FILE&quot;)&quot; hermes</pre><ul><li><strong>WebSocket localhost check via a socat sidecar.</strong> The dashboard hardcodes a rule that WebSocket connections must originate from loopback (127.0.0.1/localhost), with no config override. In Kubernetes, service-routed traffic arrives from the pod’s cluster IP and gets rejected. A tiny socat sidecar transparently relays external connections through loopback, so every connection looks loopback-originated to the dashboard and the chain completes.</li><li><strong>npm workspace repair.</strong> The upstream image ships an incomplete npm workspace (one package has its manifest but not its materialised files) and the lockfile check skips reinstalling it, so the UI crashes with a missing-module error. The dashboard wrapper detects the missing file and runs a targeted npm install (preserving proxy settings) at boot.</li><li><strong>Multi-library CA trust.</strong> A TLS-intercepting proxy re-signs every upstream certificate with its own certificate authority, and the pod has <em>four</em> different TLS clients that each trust a different bundle. You have to point all four at the proxy’s CA, or one library rejects the re-signed cert with an error that surfaces in a completely different component than the missing setting:</li></ul><pre>export AWS_CA_BUNDLE=/etc/crabtrap/ca.pem        # boto3 (AWS SDK)<br>export REQUESTS_CA_BUNDLE=/etc/crabtrap/ca.pem   # Python requests<br>export SSL_CERT_FILE=/etc/crabtrap/ca.pem        # httpx / the model SDKs<br>export NODE_EXTRA_CA_CERTS=/etc/crabtrap/ca.pem  # the Node UI</pre><h3>Supply chain: Know Exactly What’s Running</h3><p>Agent plugins and tool integrations are often unsigned, unvetted third-party code running with developer-level trust, so we treat the images themselves as part of the attack surface. Every image reference in the platform is <strong>pinned by sha256 digest</strong>, stored in a <strong>private, tag-immutable registry</strong> with scan-on-push, and built on <strong>distroless bases</strong>. That makes each pod’s spec content-addressable: the same manifest yields the same bytes on every node, every time.</p><p>We learned this the hard way. One week, newly created pods began failing to boot with agent init failed: anthropic required, while every existing pod (same manifest, same image: line) kept running fine. After a half-day of ruling out our own platform, the truth landed upstream: the :latest tag had been rebuilt with several Python dependencies silently dropped, so pods that pulled before the rebuild had the good bytes cached on their node and pods that pulled after got the broken ones. Two pods, one image: line, genuinely different code, and then it happened a second time. Every image is now pinned to an immutable sha256 digest, so “the same manifest” really does mean “the same bytes,” on every node, forever.</p><h3>Onboarding in Under Two Minutes</h3><p>Adding a person is one command:</p><pre>bin/onboard-user.sh firstname.lastname@razorpay.com</pre><p>That script canonicalises the email to a username, provisions a scoped model-gateway key, renders the Kubernetes manifests from templates, applies them in dependency order (namespace → service account / cloud identity → volume → config → deployment → service), wires up the per-user audit-proxy channel, waits for the pod to come up, and prints the URL. No cloud console, no identity-provider clicks, no manual IAM. <strong>Under two minutes, start to finish.</strong> Offboarding is just as clean: snapshot the volume for preservation, then delete the namespace.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7kayZjgCh3fvYyjbrlDbIA.png" /><figcaption><strong>Figure 7: Provisioning flow.</strong></figcaption></figure><h3>Conclusion</h3><p>AI agents are quickly becoming the highest-privilege software inside a company: they read source, run commands, hold live credentials, and increasingly act on their own. The question is no longer whether employees will use them. It’s whether they’ll use them inside infrastructure built for that privilege, or on a laptop where nobody can see.</p><p>For us, the answer came down to one principle, and everything in this post is a consequence of it: <strong>isolation is a property of the infrastructure, not the application.</strong> One pod, one namespace, one identity, one encrypted volume per person; every outbound call screened and audited; skills each person owns privately. Take that seriously and something useful happens: the secure path also becomes the <em>easy</em> path, genuinely faster than running an agent on a laptop. That is the only version of security that actually gets adopted.</p><p>Two hundred and twenty of our colleagues are already on it, most of the work happening while they sleep.</p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f91d56bea3f1" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/running-hermes-at-razorpay-a-network-isolated-self-improving-second-brain-for-every-employee-f91d56bea3f1">Running Hermes at Razorpay: a Network-isolated, Self-improving “Second Brain” for every Employee</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Turning Scattered Data Into Queryable Segments at Scale: How Razorpay Built Its Customer Data…]]></title>
            <link>https://engineering.razorpay.com/turning-scattered-data-into-queryable-segments-at-scale-how-razorpay-built-its-customer-data-3937c4b012de?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/3937c4b012de</guid>
            <dc:creator><![CDATA[Varun Meka]]></dc:creator>
            <pubDate>Fri, 26 Jun 2026 08:06:33 GMT</pubDate>
            <atom:updated>2026-06-26T08:06:32.189Z</atom:updated>
            <content:encoded><![CDATA[<h3><strong>Turning Scattered Data Into Queryable Segments at Scale: How Razorpay Built Its Customer Data Platform</strong></h3><p><em>A consent-native CDP that serves audience segments across 500M+ user profiles in under 30ms, with PII isolated to the source systems.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LOAyanf4lVSDum5COfJAJA.png" /></figure><h3>The Problem We Were Solving</h3><p>A customer opens her favourite online shopping app, adds a few items to her cart, and pays ₹1,200 via UPI, powered invisibly by Razorpay. A week later she returns and pays using a saved Visa card from her laptop. Later that month, she places a larger ₹8,500 order through net banking from work.</p><p>Three transactions. Three different payment instruments. Three different devices. To the merchant’s engineering team, and to Razorpay’s data systems, these could look like three completely different people, unless you’ve done the hard work of figuring out they’re all the same customer.</p><p>Now suppose this is a D2C fashion brand approaching their Diwali sale. The merchant’s growth team has a clear plan: <em>“Identify customers who have transacted at least once in the last 30 days, have spent more than ₹5,000 cumulatively this quarter, and haven’t enrolled in our loyalty programme. Send them an early-access nudge with a personalized discount 48 hours before the public sale opens.”</em></p><p>A year ago, answering that question at Razorpay meant filing a cross-team data request, waiting for an analyst to write a custom Spark job, and getting an answer in 2–3 days. By the time the merchant had the segment, the Diwali sale was already live. The early-access window had closed. The campaign got sent to a broader, less-targeted audience, wasting spend on customers who would have bought anyway and leaving cold customers untouched.</p><p>Now multiply that pain by millions of merchants. Razorpay powers payments and growth for over 12 million merchants. From D2C fashion brands and SaaS startups to subscription platforms, ed-tech companies, and the 2 million+ local merchants accepting QR payments every day. Together, they process billions of transactions. Every one of those merchants is, in their own way, trying to grow. Running a sale, recovering an abandoned cart, nudging a churning customer, and identifying their next 1,000 high-value buyers.</p><p>We knew this was a fundamental capability gap that needed structural solving. That’s what drove us to build the Customer Data Platform (CDP), an in-house platform that sits at the heart of Razorpay’s data-driven product decisions.</p><p>DPDPA also reshaped what the platform had to be. India’s Digital Personal Data Protection Act introduced strict requirements around consent-scoped data processing, purpose-specific access, and the ability to honor user consent decisions at the data layer rather than the application layer. The CDP was built with these requirements baked into the architecture rather than bolted on afterwards.</p><h3>The Architecture</h3><p>Before diving in, it’s worth mentioning why a CDP at this scale is difficult. Teams that have worked on similar systems will recognize the challenge immediately. It has to balance data freshness, low latency, consent enforcement, cost, and correctness simultaneously. Few of these dimensions trade off against each other.<br><br>Razorpay’s EDW holds billions of derived attribute rows across 500M+ user profiles, refreshed daily from payment transaction data flowing in at tens of thousands per second. These attributes are stored in S3 with server-side encryption using client-managed KMS keys, which keeps access strict and explicitly enforced. A single segment definition touches multiple attribute tables, each with hundreds of millions of rows. Naive joins blow up Spark cluster memory in seconds.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*h-f6FqleTR2lO0a_mDOhpg.png" /></figure><p>Real-time membership checks complete in under 30ms, including network hops between services, and the current system sustains this at more than 1,500 RPS. Cost is another major constraint. At 500M+ profiles and hundreds of active segments, even small inefficiencies in compute or storage compound into significant monthly spend.</p><p>Correctness is equally important. Segments directly power campaigns that reach real merchants and customers. Even a small mismatch in segment membership can result in the wrong campaign being sent to users, impacting merchant trust and creating compliance risks.</p><h3>Stage 1: The Segmentation DAG, from Rules to Membership Lists</h3><h4>What a Segment Definition Looks Like</h4><p>A segment is a named, rule-based collection of user identifiers. Rules are expressed as JSONB configs of AND/OR/NOT conditions over derived attributes:</p><pre>{<br>  &quot;name&quot;: &quot;seg_loyalty_nudge_candidates&quot;,<br>  &quot;purpose&quot;: &quot;internal_crm&quot;,<br>  &quot;refresh_config&quot;: {<br>    &quot;frequency&quot;: &quot;daily&quot;,<br>    &quot;ttl_days&quot;: 30<br>  },<br>  &quot;rules&quot;: {<br>    &quot;operator&quot;: &quot;AND&quot;,<br>    &quot;conditions&quot;: [<br>      {<br>        &quot;attribute&quot;: &quot;avg_order_value&quot;,<br>        &quot;op&quot;: &quot;eq&quot;,<br>        &quot;value&quot;: 5000<br>      },<br>      {<br>        &quot;attribute&quot;: &quot;loyalty_enrolled&quot;,<br>        &quot;op&quot;: &quot;eq&quot;,<br>        &quot;value&quot;: false<br>      }<br>    ]<br>  }<br>}</pre><h4>How the DAG Runs</h4><p>A segment definition looks simple: a few conditions, a refresh cadence, and an output purpose. But executing it at scale is expensive. Every run may involve scanning hundreds of millions of profiles, joining across large attribute tables, applying consent filters, and generating outputs for multiple downstream serving systems.</p><p>The Airflow DAG runs daily and processes two categories of work:</p><ul><li>New segment requests from segment_requests</li><li>Existing segments scheduled for refresh based on refresh_config.frequency</li></ul><p>For each segment, the DAG first determines the minimum set of attribute tables required for execution. The EDW holds hundreds of derived attributes, many backed by large tables with hundreds of millions of rows. Loading all attributes for every segment run would unnecessarily increase Spark memory usage and shuffle overhead. To avoid this, the DAG parses the JSONB rule tree, extracts only the referenced attributes, and builds a targeted load plan for execution.</p><p>The Spark job evaluates the segment conditions and produces the final membership list of users matching the rules. The computed output is then written to S3 in a format based on the downstream serving requirement, and is encrypted at rest using a KMS key.</p><p>After successful generation, metadata such as segment status, output path, size, and refresh timestamps are updated in Postgres. The pipeline then emits segment.created or segment.updated events through SQS for downstream consumers.</p><h4>Segment Reuse and Deduplication</h4><p>Segment reuse was one of the most consequential decisions in the platform. It saved both compute cost and storage. Different teams often request the same segment with minor formatting differences in configuration. Without reuse, each request would independently trigger Spark computation, generate separate S3 outputs, and provision separate serving infrastructure.</p><p>To avoid this, the platform computes a deterministic hash for every segment definition. Before scheduling execution, the system checks whether an active segment with the same hash already exists. If a match is found, the computed dataset is reused instead of recomputing the segment.</p><p>The difficult part was canonicalisation. Semantically identical rules must generate the same hash even if their JSON structure differs. For example, A AND B and B AND A should resolve to the same segment identity. To achieve this, the pipeline normalises operators, sorts conditions deterministically, flattens nested logical groups, and standardises JSON ordering before hashing.</p><p>Data reuse and serving reuse are handled independently. If a matching segment already exists in S3 but a new consumer requires DynamoDB serving, the system creates a new serving configuration and loads the existing dataset into DynamoDB without rerunning the Spark computation. This separation allows the platform to minimise compute cost while still supporting different serving patterns for different consumers.</p><h3>Stage 2: Segment Ingestion, Why We Split the Pipeline in Two</h3><p>Every computed segment follows the same initial path: once the DAG finishes processing, a completion event is published to SQS. From there, the ingestion flow diverges based on the serving mode required by the segment.</p><h4>S3-Backed Segments</h4><p>For batch-only use cases, the ingestion flow is intentionally lightweight. The Segment Worker validates the event, updates the segment state in the database, and emits a segment.active event to Kafka for downstream consumption. Since these segments are served directly from S3 and do not require low-latency infrastructure, the processing completes within seconds without additional orchestration or long-running workflows.</p><h4>DynamoDB-Backed Segments</h4><p>Segments that require real-time membership checks follow a separate ingestion path. In this case, the Segment Worker starts a Temporal workflow keyed by segment_id.</p><p>The operational requirements here are very different from S3-backed segments. A DynamoDB import may involve loading millions of records from S3 into a newly provisioned table. Large imports can take several minutes and are vulnerable to transient failures such as worker crashes, S3 throttling, or DynamoDB capacity spikes. To handle this reliably, the workflow is broken into independently retryable activities:</p><ol><li><strong>Event validation.</strong> Verifies metadata such as segment ID, S3 path, record count, and checksums before ingestion begins.</li><li><strong>DynamoDB import.</strong> Reads the computed membership dataset from S3 and writes it into a new versioned DynamoDB table.</li><li><strong>Version promotion.</strong> Promotes the new table version only after import validation succeeds.</li><li><strong>Lineage logging.</strong> Stores execution metadata, retries, timestamps, and audit information for operational visibility.</li></ol><h4>Zero-Downtime Refreshes Via Table Versioning</h4><p>Each refresh creates a new DynamoDB table version:</p><ul><li>vN is the current live version</li><li>vN-1 is the previous stable version</li></ul><p>Older versions are cleaned up asynchronously. The active serving pointer is switched only after the new table is fully imported and validated. If an import fails midway, the existing live table continues serving traffic without interruption. This versioned approach avoids partial refresh visibility and ensures consumers always read from a stable dataset during segment refreshes.</p><p>Temporal also provides automatic retries with exponential backoff for transient infrastructure failures. If a worker crashes during execution, the workflow resumes from the last completed activity instead of restarting the full import.</p><h3>Stage 3: Segment serving</h3><p>At first glance, segment serving looks simple: store membership data in DynamoDB and query it during runtime. In practice, this layer has strict latency, isolation, and privacy requirements. Real-time membership checks are part of critical request paths such as ad serving and personalisation flows. Multiple segment lookups may happen within a single request, so the serving layer is designed to operate within a tight p99 latency budget of sub 30ms. [VERIFY: sub-30ms p99 claim]</p><h4>One Table Per Segment</h4><p>Each segment is stored in its own DynamoDB table: segment_&lt;segment_id&gt;_&lt;unique_version_hash&gt;.</p><p>This design isolates traffic and operational behaviour across segments:</p><ul><li>High traffic on one segment does not impact others.</li><li>Capacity can be provisioned independently per segment.</li><li>TTL, refresh lifecycle, and cleanup remain local to the segment.</li><li>Failures during ingestion or refresh stay isolated to a single segment.</li></ul><p>More tables means more operational overhead. The isolation was worth it.</p><h4>Privacy-Preserving Membership Checks</h4><p>The partition key for every table is the SHA-256 hash of the user&#39;s phone number. During lookup, the caller sends a pre-hashed identifier and receives a boolean response indicating whether the user belongs to the segment. The serving layer never receives or stores raw phone numbers. PII does not leave the originating system and is never written into DynamoDB.</p><p>This effectively turns the serving layer into a membership lookup system that answers: &quot;Is user X part of segment Y?&quot; without needing access to the actual identity of the user. This was a deliberate architectural decision to reduce the compliance and security surface area of the system.</p><h4>Multiple Serving Paths From the Same Segment</h4><p>A single computed segment can support multiple downstream serving modes simultaneously.</p><p>For example:</p><ul><li>A campaign system may use DynamoDB for low-latency eligibility checks.</li><li>A partner integration may consume the same segment as a batch export from S3.</li></ul><p>The platform tracks these independently using the segment_servings table in Postgres. This separates segment computation from segment delivery and allows different consumers to use the same underlying dataset without triggering duplicate computation.</p><h3>The Estimation Engine: Fast Audience Sizing With Theta Sketches</h3><p>One of the biggest usability challenges in the platform was segment estimation. Before the estimation engine existed, the only way to know the size of a segment was to run the full computation pipeline. That meant executing Spark jobs on the EDW, generating outputs, and waiting for the final count. Even a small change in segment conditions required rerunning the workflow, which slowed down experimentation significantly.</p><p>The goal of the estimation engine was simple: give teams a fast and reasonably accurate estimate of segment size before triggering the actual computation pipeline.</p><h4>Why We Chose Theta Sketches</h4><p>Approximate cardinality estimation is a well-known problem, and HyperLogLog (HLL) is often the default choice because of its low memory footprint. However, our segment system required more than simple cardinality estimation.</p><p>Most segment definitions involve combinations of AND, OR, and NOT. For example: (state = &#39;Maharashtra&#39; OR state = &#39;Karnataka&#39;) AND NOT (loyalty_enrolled = true).</p><p>This requires unions, intersections, and set differences across user groups. While HLL handles unions efficiently, intersections and differences rely on inclusion-exclusion approximations, where errors compound quickly for nested conditions.</p><p>Theta Sketches support these set operations directly with predictable error bounds, which made them a better fit for segment estimation.</p><h4>Building the Sketch Store</h4><p>During attribute generation, the platform creates a Theta Sketch for every attribute-value pair in the catalogue using the Apache Datasketches library.</p><p>Examples:</p><ul><li>state = &#39;Maharashtra&#39;</li><li>loyalty_enrolled = false</li><li>payment_method = &#39;UPI&#39;</li></ul><p>Each sketch contains hashed user identifiers matching that condition. These sketches are stored alongside the attribute data and refreshed periodically through batch recomputation jobs. This shifts the expensive work to an offline preprocessing step so that estimation queries do not require scanning large attribute tables at runtime.</p><h4>Query-Time Estimation</h4><p>When a team creates or edits a segment, the estimation engine parses the segment rules into a logical tree. Instead of loading raw attribute data, the engine fetches the required Theta Sketches and evaluates the tree using set operations.</p><p>The final sketch returns an estimated cardinality for the segment. Since the computation happens entirely on compact probabilistic summaries, estimates are returned within seconds without triggering Spark jobs or EDW scans.</p><p>For larger segments, the observed estimation error stays within a small range and is accurate enough for audience planning and campaign sizing decisions. For a Theta Sketch with k = 4096, the expected relative standard error is 1/√k ≈ 1.56%. In practice, for segments with more than a few thousand members, estimates land within ±3% of the true count 95% of the time. For very small segments (fewer than k distinct elements), the sketch holds exact members and returns a precise count.</p><h4>Handling Drift</h4><p>Theta Sketches are periodically rebuilt from the latest attribute datasets. As user attributes evolve over time, incremental updates alone can introduce drift between the sketches and the actual population. To maintain estimation accuracy, the platform runs scheduled rebuild jobs that regenerate sketches from the latest attribute store snapshots.</p><h4>Impact on Segment Creation</h4><p>The estimation engine changed the segment creation workflow from a long-running batch process into an interactive experience. Teams can now modify segment conditions, preview audience sizes in near real-time, and refine targeting before triggering the actual computation pipeline.</p><p>This reduced unnecessary Spark executions while making segment iteration significantly faster for internal teams.</p><h3>What We Would Say to Other Teams Building This</h3><p>A few lessons that aren&#39;t specific to customer data platforms.</p><p><strong>Compute reuse beats compute optimization.</strong> A 30% faster Spark job is a one-time win. Detecting that the job doesn&#39;t need to run at all is a permanent one. Segment reuse through deterministic hashing was the single biggest cost lever in the platform. Build canonicalisation carefully and the savings compound forever.</p><p><strong>Hash before you store.</strong> Privacy-preserving membership checks aren&#39;t just a compliance feature; they&#39;re an architectural simplification. The serving layer has nothing sensitive to protect because it never had the sensitive data in the first place. The cost is one extra hash on the caller side. The benefit is removing the entire serving layer from the PII blast radius.</p><p><strong>Approximate is usually enough.</strong> The estimation engine using Theta Sketches turned a multi-hour iteration loop into a sub-second one. Most audience planning decisions don&#39;t need exact counts; they need confidence intervals. If your platform has a slow-feedback loop blocking experimentation, ask whether the consumers actually need exact answers.</p><h3>Closing Thoughts</h3><p>The platform continues to evolve as we expand attribute coverage, improve refresh frequency, and move toward near real-time membership updates. We are also working toward making segment creation accessible to non-technical teams through self-service tooling.</p><p>Many of the architectural decisions in the platform were driven by scale, latency, and compliance constraints. The same patterns (approximate estimation using sketches, durable ingestion workflows, and privacy-preserving serving systems) are applicable beyond customer segmentation and can be useful in any large-scale audience or membership platform.</p><p>As the system grows, the challenge is no longer just computing segments efficiently, but building infrastructure that remains reliable, flexible, and operationally manageable as new use cases emerge.</p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3937c4b012de" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/turning-scattered-data-into-queryable-segments-at-scale-how-razorpay-built-its-customer-data-3937c4b012de">Turning Scattered Data Into Queryable Segments at Scale: How Razorpay Built Its Customer Data…</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From 750 Hours to 2 Hours: AI-Powered Security Triage at Razorpay]]></title>
            <link>https://engineering.razorpay.com/from-750-hours-to-2-hours-ai-powered-security-triage-at-razorpay-c8baeac3a1d3?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/c8baeac3a1d3</guid>
            <dc:creator><![CDATA[Prathamesh Joshi]]></dc:creator>
            <pubDate>Tue, 09 Jun 2026 14:56:35 GMT</pubDate>
            <atom:updated>2026-06-09T14:56:34.399Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kRfU0CmmpGyke9GWPxhzrA.png" /></figure><p><em>Co-authors: </em><a href="https://medium.com/u/dda1d60c8af5"><em>Mahlaqahaque Mh</em></a>,<em> </em><a href="https://medium.com/u/95af06757360"><em>Keertiv</em></a><em>, </em><a href="https://medium.com/u/3701c4c38244"><em>Hari Prasad Pujari</em></a></p><p><strong>How we taught AI to read code like a senior security engineer</strong></p><p>Every day, Razorpay engineers ship thousands of lines of code. Every line births new security findings. SAST scanners flag suspicious patterns. Dependency checkers find vulnerable libraries. Secret detection tools catch hardcoded credentials.</p><p>The alerts pile up. Hundreds become thousands. The backlog becomes noise.</p><p>We hit a breaking point. Developers faced security ticket counts climbing into the thousands, with most of them turning out to be false positives. The classic “alert that cried wolf” scenario played out daily. When everything is marked critical, nothing is. Developers stopped trusting security findings altogether.</p><p>Security engineers weren’t having a better time. Validating issues manually while fielding constant ad-hoc requests from frustrated developers. Trying to stop a waterfall with a teaspoon. The human bottleneck became the limiting factor in our security posture.</p><p>The core problem was simple. Traditional static analysis tools excel at finding patterns that <em>might</em> be vulnerabilities. They lack context. They can’t distinguish between a properly sanitized SQL query and a vulnerable one. Between a test API key and a production secret. Between a dangerous data flow and one protected by business logic.</p><p>For every 10 alerts, 7–8 were false positives. Manual triage became the bottleneck. Security couldn’t scale with engineering velocity.</p><p>That’s when we built what we call the Autonomous Security Special Ops system. An AI-powered engine that handles the heavy lifting so humans can focus on what actually matters.</p><h3>The Three-layer Intelligence System</h3><p>Rather than throwing more human hours at the problem, we built an AI architecture operating in three layers.</p><p><strong>L1: Context-Aware AI Triage (Live).</strong> Our intelligent first responder. Powered by 29 specialized sub-skills , it reads code context like a senior security engineer. When a SAST finding lands, L1 fetches the issue via Semgrep APIs, pulls source code from the repository through a GitHub fine-grained token, and analyzes the full context — not just the flagged line but the surrounding code, data flows, and sanitization logic. Then it validates: is this a genuine vulnerability or a false positive? Valid issues route to L2. False positives feed into rule tuning. Current accuracy: 75–80%.</p><p><strong>L2: Autonomous Remediation Bot (Live).</strong> When L1 confirms a valid finding, [VERIFY: Vyom / Slash] doesn’t just flag it. It invokes specialized security plugins for SAST, Software Composition Analysis, and hardcoded secrets. It generates secure code fixes. It creates pull requests with remediation. It logs everything for audit trails and pushes metrics to our dashboards. The bot operates continuously, processing new issues in real-time as they’re created and systematically working through the existing backlog by criticality.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ERx0StUDxdqcpsTM_R03SQ.png" /></figure><p><strong>L3: Scanner Auto-Tuning (Coming Soon).</strong> This layer will extract patterns from false positives identified by L1, fine-tune Semgrep rules in real-time, update our rule registry automatically, and create a continuous feedback loop. L3 deploys once L1 accuracy reaches 90%+. A self-improving security system that gets better at distinguishing signal from noise with minimal human intervention.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W32tc0szO7M5vVYmpWytgw.png" /></figure><h3>Human vs. Bot: The Math</h3><p>The economics tell the story.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0BFHwory93TI-Zhsf8K6jw.png" /></figure><p>Roughly 960x speed-up. The throughput number is the headline, but consistency matters as much in practice. Manual review at 800 findings deep on a Friday afternoon is not the same review as finding #1 on Monday morning. The AI applies the same rigorous analysis to finding 23,000 as it did to the first one.</p><h3>How L1 Actually Works (and how to build your own)</h3><p>A few design decisions made the difference between demo and production.</p><p><strong>Progressive disclosure for skill loading.</strong> We only load relevant sub-skills (2–4 per finding) instead of all 29. This reduced tokens per analysis from roughly 50K to 2.5K, which directly cuts cost and reduces hallucinations from context overload. More context isn’t always better. The <em>right</em> context, scoped tightly, almost always is.</p><p><strong>A skill router on metadata.</strong> Finding metadata (rule ID, file type, framework) routes to the right skills. Multiple skills then run in parallel, with results aggregated by weighted consensus. A SQL injection finding in a Go HTTP handler loads different skills than the same rule firing in a Python data pipeline.</p><p><strong>Grounded verdicts only.</strong> The AI must pull actual source code (50–100 lines of context) before any verdict. No source code, no verdict. This prevents speculation, the failure mode where the model produces a confident-sounding analysis based on its priors rather than the actual code in front of it.</p><p><strong>Confidence-gated automation.</strong> Only verdicts above 85% confidence trigger auto-remediation. Lower-confidence findings go to human review. This isn’t a limit on the AI; it’s a contract with the security team about what gets to ship without their eyes on it.</p><p><strong>Specialization over generalization.</strong> We started with a general-purpose code analyzer. It hit ~60% accuracy. Mediocre at everything. When we broke it into 29 specialized sub-skills (each tuned for specific vulnerability classes, frameworks, and code patterns), accuracy jumped to 75–80%. Security knowledge needs to be deep, not just broad.</p><p>For anyone building similar systems: the model isn’t the hard part. The skill architecture is.</p><h3>How the Pipeline Holds Together: Harness</h3><p>Production AI workflows have the same requirements as any other production workflow. Idempotency. Retries. Observability. Secret management. Auditability of every decision.</p><p>Harness is our orchestration backbone for this system. It gives us:</p><ul><li><strong>Pipeline-as-code:</strong> Every triage run versioned, auditable, reproducible</li><li><strong>Native cron + webhook triggers:</strong> L1 runs every 6 hours; L2 reacts to new issues in real-time</li><li><strong>Conditional execution:</strong> Routing between L1 verdicts, L2 remediation, L3 tuning</li><li><strong>Built-in observability + RBAC:</strong> Critical for security workloads where every AI decision must be traceable</li><li><strong>Human evaluation in the loop:</strong> All triaged results produced by the skills, including Semgrep rule metrics and per-rule issue counts, are reviewed by a human before any action is taken</li></ul><p>Without this infrastructure layer, an AI agent making security decisions in production is a liability. The model can be world-class and the system can still fail in ways nobody can debug.</p><h3>The stage-by-stage evolution</h3><p>The current system didn’t emerge fully formed. It evolved through four distinct stages, each unlocking a different operating model.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DntmCLLtf-gUYi1-xTHLEA.png" /></figure><p>*Manual TP rate reflects what humans confirmed valid, but with significant fatigue-driven inconsistency.</p><p>The improvements aren’t just incremental. Each stage changed what the security team could <em>do</em>. Manual review forced sampling. Rule-based filters reduced noise but couldn’t read context. The AI agent could read context but couldn’t yet learn from disagreements. The feedback loop closes that gap.</p><h3>How the System Gets Smarter</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m07WTbp-ssv5h7wCvn7orA.png" /></figure><p>This is where it goes from useful to compounding.</p><p>The mechanism is straightforward but the details matter:</p><ol><li>The AI agent triages a finding and posts its verdict directly on the DevRev ticket.</li><li>If a security engineer disagrees, they add a comment explaining why. “This is a real issue because input flows to eval() after a sanitization bypass.” Or: “This is FP because the framework auto-escapes here.”</li><li>The AI agent reads comments on the ticket on the next run.</li><li>The reasoning gets converted into either an update to an existing skill or a brand-new skill.</li><li>The next run applies the new knowledge across all future findings of that type.</li></ol><p>The critical safety detail: <strong>comment access is restricted.</strong> The AI only reads comments from a trusted allowlist of security engineers and approved reviewers. Comments from other users, developers, PMs, anyone outside the loop are ignored entirely. This prevents skill drift. A developer can’t trick the AI into marking real vulnerabilities as false positives by leaving misleading comments on a ticket they want closed.</p><p>The result is that tribal knowledge gets captured once and applied at scale. A senior security engineer’s reasoning about a tricky framework-specific edge case becomes a skill that every future finding benefits from. Month-over-month, accuracy climbs. Quarter-over-quarter, FP rates drop.</p><p>Most AI security tools are static. They ship with a fixed set of behaviors and stay that way until the next vendor release. Ours improves every week without retraining the underlying model. The feedback loop is the difference between a tool that helps once and a system that compounds.</p><h3>What We Learned</h3><p>A few lessons that aren’t specific to security.</p><p><strong>Context is everything, and less is often more.</strong> Feeding more context doesn’t help. Feeding the <em>right</em> context does. Progressive disclosure was the single highest-leverage decision we made.</p><p><strong>Specialization beats generalization.</strong> A general security analyzer is mediocre at everything. 29 specialists are excellent at their specific domain. Anyone building agentic systems on production data should resist the urge to build a single “do everything” agent. The performance ceiling is much lower than you’d expect.</p><p><strong>Production infrastructure matters as much as the model.</strong> Without Harness’s orchestration, observability, and audit trails, an AI agent making decisions in production is a liability. The model can be excellent and the system can still fail in ways nobody can debug or remediate.</p><p><strong>Feedback loops are the compounding asset.</strong> A one-time accuracy improvement is a fix. A learning system is an asset. The difference is the feedback loop architecture, not the model.</p><p>Instead of spending hours triaging obvious false positives, our security team now focuses on reviewing edge cases the AI flags as uncertain, updating skills as new vulnerability patterns emerge, making strategic security architecture decisions, and responding to actual incidents.</p><h4><strong>Open Questions</strong></h4><p>How much context is enough? We keep context tight today. But as models get better and we push accuracy higher, that number will change. We’re still figuring out the right balance.</p><p>On the feedback loop: The system learns from security engineer comments, but how do you prevent skill drift over time as edge cases accumulate? Quality control on the skills themselves is still evolving.</p><p>On accuracy: How do we get from 75–80% to 95%+? The path isn’t fully clear yet; more skills, better training data, or both (we’re actively working on both).</p><p>Cross-repo and transitive context remains an open problem. The AI currently operates within a single repository at a time, which means a vulnerability in a shared SDK can silently propagate to dozens of downstream services, this remains an active area of work.</p><h3>Beyond SAST: The Expanding Scope</h3><p>We’re not stopping at static analysis.</p><p>The same architecture that conquered SAST is now being trained on Software Composition Analysis. Identifying vulnerable dependencies. Understanding transitive risk. Prioritizing which CVEs actually matter in our specific usage context rather than treating every CVSS score as gospel.</p><p>And on secrets detection. Moving beyond regex patterns to understand what actually constitutes a dangerous secret versus a test API key or a public configuration token. The same skill architecture, retrained for a different vulnerability class.</p><p>The platform is extensible by design. Each new security domain gets its own specialized sub-skills. The three-layer pattern (triage, remediation, auto-tuning) applies universally. The feedback loop mechanism works the same way regardless of vulnerability class.</p><h3>The Bigger Shift</h3><p>Traditional security models assumed small, controlled codebases with limited changes. Security reviews were gates. Necessary friction points that slowed things down.</p><p>That model broke when engineering teams grew to hundreds of developers shipping multiple times daily.</p><p>The new model is autonomous, continuous, and learning. Security isn’t a gate. It’s an intelligent system running in parallel with development. Automatically identifying real risks. Fixing what can be fixed. Tuning tools to reduce noise. Escalating only what needs human judgment.</p><p>A one-person security team can now manage what used to take five. Not because the work got smaller, but because the work got smarter.</p><p>AI doesn’t replace security engineers. It makes them exponentially more effective by handling the mechanical parts that don’t require human creativity, then learning from their judgment every time it gets one wrong.</p><p>The Autonomous Security Special Ops system is live at Razorpay. L1 triage runs every 6 hours. L2 remediation processes new issues in real-time. L3 auto-tuning deploys once L1 accuracy reaches 90%+. The feedback loop runs continuously, with every disagreement between a security engineer and the AI becoming a skill update for the next cycle.</p><p>Next month will be better than this month. Next quarter will be better than this quarter. That’s the power of systems that learn rather than systems that just execute.</p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c8baeac3a1d3" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/from-750-hours-to-2-hours-ai-powered-security-triage-at-razorpay-c8baeac3a1d3">From 750 Hours to 2 Hours: AI-Powered Security Triage at Razorpay</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Razorpay Oncall Agent: From 30-Minute Investigations to 90-Second AI Analysis]]></title>
            <link>https://engineering.razorpay.com/razorpay-oncall-agent-from-30-minute-investigations-to-90-second-ai-analysis-5be7bcc461a4?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/5be7bcc461a4</guid>
            <dc:creator><![CDATA[Anuj Gupta]]></dc:creator>
            <pubDate>Wed, 29 Apr 2026 06:56:11 GMT</pubDate>
            <atom:updated>2026-04-29T09:09:00.780Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Lz-X1A1_kkt-qjTA" /></figure><h3>Our on-call engineers were spending 30 minutes investigating every production alert. Here’s what happened when we automated it.</h3><p>At 3 AM, alerts don’t care about your sleep schedule.</p><p>When our payment infrastructure threw an error last month, our on-call engineer spent 32 minutes jumping between six different monitoring systems before understanding what was broken. One tool for metrics. Another for logs. Third tool for pod health. And multiple more for infrastructure, deployment history and database health.</p><p>By the time they identified the root cause (a bad deployment), payment failures had already impacted customers for nearly 40 minutes.</p><p>This wasn’t their fault. They followed our runbook perfectly. The problem was that no single system could tell them “here’s what’s wrong and why.” They had to manually connect dots across disconnected observability tools.</p><p>That’s when we asked ourselves: what if AI could do this investigation for us?</p><h3>The Metric Nobody Optimizes For</h3><p>The SRE world talks endlessly about Mean Time to Detect (how fast you catch problems) and Mean Time to Resolve (how fast you fix them).</p><p>But there’s a critical phase hiding between them: <strong>Mean Time to Investigate</strong>.</p><p>MTTI is the gap from “we know it’s broken” to “we know what to fix.” At Razorpay, this phase was consuming 20–40 minutes per incident. With 15–20 incidents weekly, that’s 6–8 hours of engineering time spent doing repetitive investigative work.</p><p>Worse, the quality was inconsistent. Senior engineers knew exactly which systems to check for payment alerts. Junior engineers sometimes checked irrelevant dashboards or missed critical correlations. The investigation depended entirely on who was on-call that night.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ymv3Zx8vqudjf_ma" /></figure><h3>What We Built (And Why It Works)</h3><p>Razorpay Oncall Agent is a multi-agent AI system that automates incident investigation. The architecture is built on LangGraph, a framework for creating stateful workflows with conditional logic, and uses LLM as the reasoning engine.</p><p>Here’s how the components work together. A <strong>Supervisor Agent</strong> acts as the incident commander, receiving alerts from an alerting tool and creating an investigation strategy. It queries our RAG (Retrieval-Augmented Generation) systems for context: one RAG stores application architecture and dependencies, another stores alert-specific diagnostic runbooks. This contextual grounding ensures investigations aren’t generic but tailored to our specific services and known failure patterns.</p><p>The Supervisor then dispatches tasks to <strong>Specialist Agents</strong> that operate in parallel. The Kubernetes Agent checks pod health and recent deployments. The Observability Agent analyzes error logs. The PromQL Agent queries performance metrics. The AWS Agent validates infrastructure health. Each agent is domain-focused, executing specific checks within 5–8 seconds.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OEPcynmroZ60PuN9jcOxHA.png" /></figure><p>As agents complete their investigations, findings get stored in <strong>Memory</strong> as structured evidence: what was checked, what was found, confidence levels, and supporting data. The Supervisor correlates all evidence, builds an <strong>Incident Evidence Timeline</strong> ordering events by timestamp, scores multiple hypotheses based on temporal correlation and evidence strength, and selects the most likely root cause. The entire process completes in under 90 seconds, posting a structured analysis to Slack with citations, confidence scores, and recommended actions.</p><p>The system learns continuously. Every investigated case gets stored back into the RAG systems, enriching future investigations with institutional knowledge. This compounding effect means the system gets more accurate over time as it encounters more incident patterns.</p><p>The on-call engineer still gets the 3 AM alert. But now it comes with a complete investigation already done.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*wR1DIYb5-4XHeb-E" /></figure><h3>The Business Impact</h3><p>Three months into shadow mode, the results are compelling:</p><p><strong>80% MTTI reduction.</strong> Investigations that took 30 minutes now complete in 90 seconds. That’s 25 minutes saved per incident.</p><p><strong>50–60% MTTR improvement.</strong> Faster investigation means faster resolution. Less customer impact, less revenue at risk.</p><p><strong>Consistency across the team.</strong> Junior engineers now receive the same quality analysis as senior engineers. Knowledge doesn’t walk out the door when someone leaves.</p><p><strong>Reduced on-call stress.</strong> Engineers report feeling more confident taking alerts because they know the AI will do the initial investigation.</p><p>The time savings compound. Six to eight hours of engineering time saved weekly. That’s time our SRE team now spends on prevention: improving monitoring, building resilience, reducing the incidents that fire in the first place.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*BA0f5sfeUuddZGm0" /></figure><h3>The Leadership Lesson</h3><p>Here’s what building Oncall Agent taught us about AI in operations.</p><p><strong>Specialize, don’t generalize.</strong> We tried building one AI that understood everything. It failed. Breaking the problem into specialist agents that collaborate worked beautifully. This mirrors how expert teams actually operate.</p><p><strong>Start with the painful, repetitive work.</strong> We didn’t start by automating complex edge cases. We automated the investigation pattern engineers repeat dozens of times weekly. The 80/20 rule applies to automation too.</p><p><strong>Build for continuous improvement.</strong> Every incident Oncall Agent investigates feeds back into its knowledge base. The system gets smarter with every case it processes. That compounding effect is what makes AI systems genuinely valuable over time.</p><h3>The Question I Keep Asking</h3><p>Is this the future of incident response?</p><p>I think so. Not because AI will replace SRE teams (it won’t), but because the complexity of modern distributed systems is growing faster than our ability to hire and train people who can understand them.</p><p>We need systems that can do the repetitive investigative work so humans can focus on the genuinely complex problems that require creativity, judgment, and experience.</p><p>That’s not replacing engineers. That’s letting them be engineers.</p><p><em>Razorpay Oncall Agent is currently in shadow mode at Razorpay, providing 90-second root cause analyses for production incidents. We’re targeting 80%+ accuracy and plan to roll out to production after validating across hundreds of incident cases.</em></p><p><em>Leading engineering teams at scale? I’d be curious to hear how you’re thinking about AI in operations. What metrics matter most to you? Where are you seeing the biggest time sinks?</em></p><p><em>Drop your thoughts in the comments.</em></p><p><em>Editor:</em><a href="https://medium.com/u/0ff40057ddde"><em> Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5be7bcc461a4" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/razorpay-oncall-agent-from-30-minute-investigations-to-90-second-ai-analysis-5be7bcc461a4">Razorpay Oncall Agent: From 30-Minute Investigations to 90-Second AI Analysis</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Checkout Frustration Razorpay Fixed: Combining Payment Methods]]></title>
            <link>https://engineering.razorpay.com/the-checkout-frustration-razorpay-fixed-combining-payment-methods-0e0b05fdf104?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/0e0b05fdf104</guid>
            <category><![CDATA[payments]]></category>
            <category><![CDATA[payment-gateway]]></category>
            <category><![CDATA[fintech]]></category>
            <category><![CDATA[razorpay]]></category>
            <dc:creator><![CDATA[Vatsal Mehta]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 09:56:17 GMT</pubDate>
            <atom:updated>2026-04-08T09:56:15.716Z</atom:updated>
            <content:encoded><![CDATA[<h3>You have a $50 gift card. Your cart is $75. The checkout says “pick one payment method.” This is a solvable problem.</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HIPSrrpQDuTSmEP9cxu0ow.png" /></figure><p>If you’ve ever tried to use a gift card for an online purchase, you know this frustration intimately.</p><p>The gift card covers most of it, but not quite all. The checkout forces you to choose: use the gift card and abandon some items, or ignore the gift card and pay the full amount another way. Either choice feels wrong.</p><p>This isn’t a technical limitation of payment processing. It’s an architectural one. Most payment systems treat each method as an isolated, complete transaction. You pay with a card OR UPI OR a gift card. The concept of composing multiple methods to fulfill a single order simply doesn’t exist in traditional payment gateway architectures.</p><p>At Razorpay, this limitation was costing merchants real money. Gift card redemption rates suffered because customers abandoned partial-value cards. Average order values stayed lower because customers couldn’t combine store credit with additional payment. The business case for solving this was clear.</p><p>That’s why we built <strong>Linked Payments</strong>, a system that treats payment methods as composable building blocks. Customers can now use a gift card for $50, then cover the remaining $25 via card, UPI, or any other method. The system handles authorization sequencing, failure recovery, and settlement splitting automatically.</p><h3>The Complexity Hidden in “Just Combine Them”</h3><p>The challenge sounds simple until you consider what payment systems actually do.</p><p>Traditional payment flows are beautifully simple. Customer initiates payment. System authorizes the full amount from one method. If authorization succeeds, capture the funds. Settle to the merchant. Either the payment worked or it didn’t. One authorization, one capture, one settlement.</p><p>Linked payments shatter this simplicity.</p><p>Now you have multiple authorizations for a single order. Sequential dependencies where the second payment only happens if the first succeeds. Partial failure scenarios where you need to handle one method succeeding while another fails. Split settlements where money comes from different sources.</p><p>Here’s what makes this architecturally challenging at Razorpay: <strong>each payment method has its own microservice and its own database</strong>. Gift cards are managed by one service, UPI by another, cards by yet another. In a monolithic architecture, coordinating sequential payments would be straightforward with database transactions. But in a microservices architecture, maintaining consistency across independent services with separate databases requires explicit orchestration, distributed state management, and careful handling of partial failures.</p><p>The critical design decision was this: <strong>we don’t capture any payments until all authorizations in the chain succeed</strong>. This two-phase approach (authorize all, then capture all) elegantly handles failure scenarios. If the gift card authorizes but UPI fails, we only have authorization holds, not actual captures. The system simply reverses the authorizations, and critically, these reversals don’t incur charges to merchants because no money was captured. The customer can retry with a different combination.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kt97kE5C5oDBGkUrPXtnXw.png" /></figure><h3>The Architecture That Makes It Invisible</h3><p>Despite internal complexity, we designed LinkedPayments to be completely transparent to merchants. Here’s one of the key architectural advantages: <strong>merchants don’t need to create special orders for linked payments</strong>.</p><p>This matters because you never know upfront whether a customer will combine payment methods or use just one. Forcing merchants to anticipate this during order creation would be impractical.</p><p>Instead, merchants create orders exactly like any normal payment. Just amount and currency. That’s it.</p><p>The magic happens dynamically at checkout. When customers reach the payment page and select “Use Gift Card + Another Method,” the LinkedPayments flow activates automatically. The system handles the entire chain without merchants having anticipated this during order creation. Everything updates dynamically based on the customer’s actual payment preference.</p><p>Webhook events follow a similar principle of simplicity. Rather than sending merchants a confusing stream of partial updates, <strong>we withhold authorization webhooks until all payments in the chain are authorized</strong>. This prevents merchants from receiving premature notifications suggesting the order is ready when subsequent payments could still fail. Only after all authorizations succeed do merchants receive notifications. This design choice simplifies merchant webhook handling dramatically.</p><h3>What Happens When Things Go Wrong</h3><p>Payment systems are distributed. Failures happen. LinkedPayments needed to handle these gracefully.</p><p>The two-phase authorization pattern handles most failures elegantly. If any authorization in the chain fails, no money has been captured yet. The system reverses authorization holds and the customer retries. No charges, no complicated refund logic.</p><p>For refunds on successful orders, the system provides <strong>merchant flexibility</strong>. Merchants can choose to refund from the gift card, from UPI, or split the refund across both methods based on their business policies or customer preferences. The system calculates the distribution, triggers appropriate reversals, and updates settlement records.</p><p><strong>Idempotency</strong> ensures network issues don’t cause double-processing. Every authorization, capture, and reversal uses idempotency keys. Retries naturally use the same key, making operations safely repeatable.</p><p>The <strong>state machine</strong> provides reliability. Each payment progresses through well-defined states (Created, authorized, captured, refunded) with explicit transitions. When something gets stuck, you can see exactly where in the flow it stopped and what the next valid transitions are.</p><h3>The Part That Just Works</h3><p>Here’s one of the most elegant design choices: <strong>reconciliation and settlement work out-of-the-box without requiring any changes</strong>.</p><p>The system was architected so each linked payment behaves like an independent payment from the settlement perspective. When $50 comes from a gift card and $25 from UPI, existing settlement infrastructure treats them as two separate transactions associated with the same order. The gift card portion settles according to existing gift card rules. The UPI portion settles through standard UPI flows.</p><p>Merchants receive settlement reports that automatically break down amounts by payment method using the same reporting infrastructure they already use. No new report formats, no special reconciliation processes, no integration work.</p><p>This design choice meant we could ship LinkedPayments without merchants updating their financial operations, accounting integrations, or reconciliation workflows.</p><h3>The Real-World Impact</h3><p>The business impact of LinkedPayments is currently focused on <strong>gift card programs</strong>, where we’ve seen measurable improvements.</p><p><strong>Gift card redemption rates increased</strong>. Customers who previously abandoned partial-value cards now use them confidently, knowing they can combine with other methods. This drives loyalty and repeat purchases.</p><p><strong>Average order values grew</strong> for merchants offering gift cards. When customers can apply gift card balances as partial payment, they’re more willing to make larger purchases. The psychology of “I’m already getting $50 off” encourages adding more to the cart.</p><p><strong>Payment success rates improved</strong> through flexibility. If a card payment fails, customers can try splitting it with a gift card and smaller card payment. This converts failed checkouts into successful orders.</p><p>However, LinkedPayments was designed to support <strong>any combination of payment methods</strong>. The same infrastructure enabling gift card + UPI can support store credit + card, wallet + netbanking, corporate credit + personal card, or multiple cards for high-value purchases. These use cases represent future expansion opportunities as we validate the architecture with current gift card adoption.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2w0yBGKRPNgjb563BDhC1g.png" /></figure><h3>The Composability Lesson</h3><p>LinkedPayments demonstrates a broader principle about platform design: <strong>composability multiplies capability</strong>.</p><p>When you design payment methods as modular, chainable units rather than isolated silos, you enable use cases you didn’t originally anticipate. We’ve already seen merchants exploring scenarios we never explicitly designed for: corporate expense reimbursements, subscription payments with account credit, group purchases with multiple contributors.</p><p>This composability emerges naturally from the architecture. Because the orchestration logic treats payment methods uniformly and the state machine handles arbitrary sequencing, any combination of supported methods “just works” without special-case implementation.</p><p>The lesson applies beyond payments. When building platform capabilities, investing in composability early pays dividends. Each new composable unit doesn’t just enable one new use case; it enables N new combinations with existing units. The value grows combinatorially rather than linearly.</p><h3>When Architecture Decisions Compound</h3><p>Building LinkedPayments taught us that the right architectural choices create long-term leverage.</p><p><strong>Event-driven coordination</strong> through Kafka and order_meta allowed sequential payment processing while maintaining loose coupling between microservices.</p><p><strong>Two-phase authorization</strong> (authorize all, then capture all) eliminated an entire class of complex failure scenarios.</p><p><strong>Out-of-box settlement</strong> meant zero merchant integration work, dramatically lowering adoption barriers.</p><p>These weren’t just technical decisions; they were product decisions about how to build systems that merchants could trust and adopt easily. The technical elegance enabled the business outcome.</p><p><em>LinkedPayments is currently powering gift card combinations at Razorpay, with architecture designed to support any payment method pairing. The system continues to evolve as we explore additional use cases and gather merchant feedback on the composability model.</em></p><p><em>Building payment infrastructure or fintech platforms? The balance between system complexity and user simplicity is always interesting. What approaches have worked for your architecture?</em></p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0e0b05fdf104" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/the-checkout-frustration-razorpay-fixed-combining-payment-methods-0e0b05fdf104">The Checkout Frustration Razorpay Fixed: Combining Payment Methods</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Multi-Provider Rewards Platform: An E-Commerce Approach to Rewards at Scale]]></title>
            <link>https://engineering.razorpay.com/building-a-multi-provider-rewards-platform-an-e-commerce-approach-to-rewards-at-scale-0be0f2b9131f?source=rss----6407ad2e59af---4</link>
            <guid isPermaLink="false">https://medium.com/p/0be0f2b9131f</guid>
            <dc:creator><![CDATA[Archit Agarwal]]></dc:creator>
            <pubDate>Wed, 01 Apr 2026 06:00:27 GMT</pubDate>
            <atom:updated>2026-04-01T06:00:25.925Z</atom:updated>
            <content:encoded><![CDATA[<h3><strong>We built a multi-provider rewards platform using e-commerce patterns. Here’s why that matters.</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u1mrywVOSxmlkDPKeyeOyA.jpeg" /></figure><p>You’re running a loyalty program. Customers are redeeming points for gift cards, ongoing offers, memberships, or experiences. Then your provider’s API times out.</p><p>You refresh. Try again. Out of stock for the denomination your customer wanted. You check another provider, but you’re not integrated with them. Your customer service team starts getting calls. The loyalty program meant to drive engagement is now creating frustration.</p><p>This scenario plays out repeatedly across fintech platforms, and it reveals a fundamental architectural problem. Most gift card platforms are tightly coupled to a single provider. When that provider fails, runs out of stock, or becomes unavailable, your entire rewards program stops. There’s no fallback, no intelligent routing, no abstraction layer handling the complexity.</p><p>At Razorpay, we power rewards programs for banks running credit card loyalty, wallet apps offering cashback, corporate gifting platforms, and incentivized advertising. A single-provider architecture couldn’t meet the diverse requirements: instant vouchers for loyalty programs (under 3 seconds), bulk fulfillment for corporate gifting, and high-volume automated distribution for ad rewards.</p><p>That’s why we built <strong>Rewards Marketplace</strong>, treating rewards distribution as an e-commerce problem rather than a simple API integration. Just like Amazon abstracts multiple sellers behind a single product listing, we abstract multiple providers behind a single reward offering. The result: 60% of orders fulfilled in under 1 seconds, zero visible stock-outs, 99.95% success rate, and seamless multi-provider routing.</p><h3>The Multi-Provider Challenge</h3><p>Building a rewards platform sounds straightforward until you encounter the real-world complexities.</p><p><strong>Provider reliability varies dramatically</strong>. One provider offers 99.5% uptime but charges premium rates. Another is cheaper but fails 3% of the time during peak hours. A third has a limited catalog but excellent API performance. If you’re locked to one provider, their limitations become your ceiling.</p><p><strong>Customer expectations don’t match provider capabilities</strong>. Loyalty programs need instant delivery (under 3 seconds). Corporate gifting can tolerate 3–5 minute fulfillment for bulk orders. These different SLA requirements mean you need both synchronous and asynchronous fulfillment paths based on order characteristics and provider capabilities.</p><p><strong>Inventory visibility is opaque</strong>. Providers rarely expose real-time stock levels. Customers discover out-of-stock items only during redemption. Worse, providers frequently disable denominations without notice. The ₹500 Amazon card that was available yesterday might be discontinued today, and you won’t know until an order fails.</p><p><strong>Overselling is a real risk</strong>. When multiple customers request vouchers simultaneously and you’re managing pre-purchased inventory, you need atomic claiming to prevent double allocation. Without proper concurrency control, you risk selling the same voucher twice.</p><p><strong>Reconciliation gets complex</strong> when you’re buying from multiple providers at different rates and settling on different timelines. Provider A settles at T+3, Provider B at T+7. Tracking purchases, sales, and settlements without leaking provider-specific logic to client systems requires careful abstraction.</p><p>These aren’t edge cases. They’re the fundamental challenges of multi-provider distribution at scale.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yrZsufyDdL-8lDWBLxYpgw.png" /></figure><h3>The E-Commerce Insight</h3><p>The breakthrough came from recognizing that rewards distribution mirrors e-commerce marketplace patterns.</p><p>Amazon doesn’t expose which seller fulfilled your order. You search for “wireless mouse,” see a product listing, place an order. Behind the scenes, Amazon routes to the optimal seller based on price, availability, shipping speed, and reliability. If one seller is out of stock, another fulfills it seamlessly.</p><p>We applied the same pattern to rewards. Customers see “Amazon Gift Card ₹500.” Behind the scenes, we route to the optimal provider based on inventory availability, API performance, cost, and current provider health. If one provider fails, another handles it automatically.</p><p>This abstraction required building three distinct layers, each solving specific problems.</p><h3>Layer 1: The Catalog (Product Discovery)</h3><p>The catalog provides a provider-agnostic view of available rewards organized hierarchically: Brand (Amazon, Flipkart, Netflix) contains Rewards (Gift Card, Subscription), which have Variants (₹500 vs ₹1000, duration, online vs offline).</p><p><strong>Provider-agnostic availability</strong> is the key feature. The catalog shows “Available” as long as ANY fulfillment source can deliver, whether that’s pre-purchased inventory or any connected provider. Users never see provider-specific failures like “Qwikcilver out of stock.” The system internally routes to alternatives.</p><p><strong>Real-time inventory synchronization</strong> runs every 6 hours, fetching catalog and stock status from all providers. When a provider disables the ₹500 denomination, the sync job detects this and marks the variant unavailable only if no other provider offers it. This keeps the catalog fresh without manual intervention.</p><p><strong>Extensibility</strong> means new brands or reward types get added without code changes. When a provider adds “Zomato” to their catalog, the next sync cycle automatically creates the brand hierarchy and makes it available for ordering immediately.</p><h3>Layer 2: The Marketplace (Order Orchestration)</h3><p>The marketplace layer orchestrates the complete order lifecycle while handling the messy reality of partial failures and multi-entity coordination.</p><p><strong>Order and item state machines</strong> track granular status. Orders have states (INITIATED, PAYMENT_PROCESSING, PROCESSING, SUCCESS, PARTIAL_SUCCESS, FAILED). Individual order items have their own states (INITIATED, PROCESSING, SUCCESS, FAILED, REFUND_INITIATED, REFUNDED). This separation enables delivering what worked while refunding what failed.</p><p>Here’s why <strong>PARTIAL_SUCCESS</strong> matters. In a multi-provider world, failures are independent. One provider might succeed while another times out. A customer orders 5 gift cards. Three succeed, two fail. With PARTIAL_SUCCESS, we deliver the three immediately and automatically refund the two that failed. The customer gets immediate value rather than waiting for the entire order to retry.</p><p><strong>Payment strategy orchestration</strong> handles different payment flows transparently. Customers can pay via Razorpay Payment Gateway (card, UPI, netbanking), loyalty points through wallet integration, or receive free rewards through direct allocation. The marketplace abstracts these differences, applying the appropriate payment strategy based on order context.</p><p><strong>Reconciliation and financial tracking</strong> happen through a provider-agnostic ledger tracking wallet debits, credits, manual transactions, and direct allocations. This enables reporting and auditing across multiple providers without exposing provider-specific settlement logic to client systems.</p><h3>Layer 3: Fulfillment and Procurement</h3><p>SKU availability sync operates on two layers to keep catalog state accurate at all times. The first is a daily reconciliation job that checks each provider for newly added SKUs and disabled denominations, ensuring the catalog reflects the latest provider catalog state. The second is an event-driven layer, providers that support webhooks push denomination change events (stockouts, disables, re-enables) in real time, and the system processes these immediately to update catalog availability. For providers that do not support webhooks, we compensate with periodic polling to ensure the catalog stays current. Together, these layers ensure users are never shown an incorrect denomination state, catching mid-window changes that a daily sync alone would miss.</p><p>Inventory-first routing via micro-procurement checks pre-purchased vouchers before calling external providers. Rather than maintaining large static inventory pools, we use micro-procurement, procuring vouchers in small batches aligned to actual demand signals. This approach delivers two key advantages: first, if a provider’s real-time API is down, we can still serve orders from the micro-procured buffer; second, latency stays low since fulfillment hits local inventory (~100ms) rather than waiting on an external provider call. The limitation is finite stock requiring a replenishment strategy, though capital requirements are lower than bulk pre-procurement.</p><p><strong>Procurement layer</strong> handles on-demand fulfillment when inventory is insufficient. For small orders (under configured threshold), it calls providers synchronously, returning vouchers in 2–8 seconds. For bulk orders, it queues async procurement jobs that complete in 60–120 seconds. The system calls providers to get the reward.</p><p><strong>Concurrency control</strong> uses pessimistic locking during inventory claiming. When multiple customers request vouchers simultaneously, SELECT FOR UPDATE locks relevant rows until the transaction commits. This prevents overselling, with lock duration typically under 50ms for standard claims.</p><h3>The Results That Matter</h3><p>The architectural approach delivered measurable improvements across customer experience, merchant operations, and platform reliability.</p><p><strong>60% of orders fulfilled in under 1 second</strong> through the inventory fast path. Customers receive instant gratification for loyalty redemptions rather than waiting for provider APIs.</p><p><strong>40% increase in loyalty program enrollment</strong> for merchants using the platform. Reliability builds trust. When customers know redemptions work consistently, they engage more with loyalty programs.</p><p><strong>80% reduction in operational toil</strong> through auto-replenishment and sync jobs. Teams no longer manually monitor inventory or coordinate provider integrations.</p><p><strong>100% prevention of overselling</strong> through atomic inventory locking</p><p><strong>Zero visible stock-outs</strong> for customers. Catalog shows “Available” as long as any provider has inventory, eliminating the “out of stock” experience common in single-provider systems.</p><p>The platform now handles diverse use cases on unified infrastructure: instant loyalty redemptions, bulk corporate gifting, and automated ad reward distribution, all using the same abstraction layers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*46yqvfCbWBSzRrBFW26JqA.png" /></figure><h3>The E-Commerce Pattern Beyond Rewards</h3><p>The interesting insight is how broadly this pattern applies.</p><p>Any domain with multiple providers or suppliers, variable availability and pricing, different SLA requirements across use cases, and need for reliability through redundancy can benefit from marketplace patterns: catalog abstraction, intelligent routing, inventory optimization, and graceful degradation.</p><p>We’ve seen similar patterns work for payment gateway selection (routing transactions across multiple payment providers), shipping provider coordination (choosing carriers based on cost and delivery speed), and even cloud infrastructure (multi-cloud strategies with provider abstraction).</p><p>The key architectural principles transfer: separate product discovery from fulfillment, abstract provider differences behind unified interfaces, handle partial failures gracefully, optimize through smart routing and inventory, and design for provider additions without client changes.</p><h3>When Abstraction Creates Resilience</h3><p>Building Rewards Marketplace taught us that the right abstractions don’t just hide complexity; they enable capabilities impossible with direct integration.</p><p><strong>Adding new providers</strong> requires zero client-side changes. The catalog, marketplace, and fulfillment layers abstract provider differences. Clients continue using the same APIs regardless of backend provider changes.</p><p><strong>Different use cases</strong> share infrastructure. The same platform serves instant loyalty redemptions and bulk corporate gifting through smart routing and dual fulfillment paths (sync/async).</p><p><strong>Partial success handling</strong> delivers value even when some providers fail. Customers get what’s available immediately rather than waiting for retry of the entire order.</p><p>The marketplace pattern transformed rewards distribution from fragile single-provider integration to resilient multi-provider platform. That resilience unlocks business capabilities that weren’t possible before: ambitious SLA commitments, diverse use case support, and reliable scale.</p><p><em>Rewards Marketplace powers gift cards, vouchers, and incentive distribution at Razorpay across loyalty programs, corporate gifting, and advertising platforms. The architecture continues to evolve as we add providers and expand reward types supported.</em></p><p><em>Building marketplace platforms or multi-provider integrations? The abstraction patterns and resilience strategies are universal. What challenges are you solving in your architecture?</em></p><p><em>Editor: </em><a href="https://medium.com/u/0ff40057ddde"><em>Parth Sawhney</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0be0f2b9131f" width="1" height="1" alt=""><hr><p><a href="https://engineering.razorpay.com/building-a-multi-provider-rewards-platform-an-e-commerce-approach-to-rewards-at-scale-0be0f2b9131f">Building a Multi-Provider Rewards Platform: An E-Commerce Approach to Rewards at Scale</a> was originally published in <a href="https://engineering.razorpay.com">Razorpay Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>