<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Grab Tech</title>
    <description>Grab&apos;s Engineering team solves critical transportation challenges and makes transport freedom a reality for 620 million people in Southeast Asia.
</description>
    <link>https://engineering.grab.com/</link>
    <atom:link href="https://engineering.grab.com/feed.xml" rel="self" type="application/rss+xml" />
    <pubDate>Thu, 06 Aug 2026 03:22:12 +0000</pubDate>
    <lastBuildDate>Thu, 06 Aug 2026 03:22:12 +0000</lastBuildDate>
    <generator>Jekyll v4.4.1</generator>
    
      <item>
        <title>How AI is transforming analytics at Grab</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;At Grab, analytics sits close to almost every decision that matters. Our north star is the democratisation of intelligence, ensuring that anyone making a business call has immediate access to trustworthy answers.&lt;/p&gt;

&lt;p&gt;Over the last two years, model capability has crossed a threshold enabling this shift. Agents now do in minutes what used to take a week: preparing the data, writing queries, running deep analysis and developing insights for business opportunities, designing experiments and interpreting the results, drafting the commentary that follows, and more. Our throughput is no longer rate-limited by how fast an individual can write code, build a deck, or run a deep-dive. It is rate-limited by &lt;strong&gt;how fast we can frame the right problem, judge the right answer, and influence the right decision&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;As autonomy climbs, an analyst’s impact moves from producing the artefact to owning the question and the call behind it, and the role evolves to become part builder, part advisor, part strategist, owning the loop rather than running it. That unlocks two things at once: work we already do, faster and at lower marginal cost, and work we could never staff before, sitting beside every product manager, business owner, and operator at the moment they decide.&lt;/p&gt;

&lt;h2 id=&quot;the-ladder&quot;&gt;The ladder&lt;/h2&gt;

&lt;p&gt;We were heavily inspired by Dan Shapiro’s framing of five levels for AI coding. We use a similar ladder that defines how much of the loop an agent should own and where human judgement stays for every analytics loop.&lt;/p&gt;

&lt;p&gt;One distinction runs across every level: who owns the loop, and where human judgement is required.&lt;/p&gt;

&lt;table&gt;
  &lt;colgroup&gt;
    &lt;col style=&quot;width: 5.5rem;&quot; /&gt;
    &lt;col style=&quot;width: 22%;&quot; /&gt;
    &lt;col style=&quot;width: 36%;&quot; /&gt;
    &lt;col style=&quot;width: 36%;&quot; /&gt;
  &lt;/colgroup&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;white-space: nowrap;&quot;&gt;Level&lt;/th&gt;
      &lt;th style=&quot;white-space: nowrap;&quot;&gt;What&lt;/th&gt;
      &lt;th&gt;Human role&lt;/th&gt;
      &lt;th&gt;Agent role&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;&lt;strong&gt;L2&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;AI-Assisted&lt;/td&gt;
      &lt;td&gt;Owns and executes every step; uses AI to draft, suggest, summarise&lt;/td&gt;
      &lt;td&gt;Drafts SQL, suggests a visualisation&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;&lt;strong&gt;L3&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Human plans, agent owns steps, human reviews&lt;/td&gt;
      &lt;td&gt;Frames the question, picks the metric, the segment, and the comparison frame, reviews evidence, owns the recommendation&lt;/td&gt;
      &lt;td&gt;Discovers data, writes and runs the query, sanity checks, drafts the write-up, flags caveats&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;&lt;strong&gt;L4&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Agent plans, agent owns workflows, human reviews&lt;/td&gt;
      &lt;td&gt;Sets intent and guardrails; reviews at gates (anomaly, novel scope, sensitive cut); owns the stakeholder relationship and sign-off&lt;/td&gt;
      &lt;td&gt;Orchestrates discovery through query, analysis, validation, narrative and publish; runs validation, escalates exceptions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;&lt;strong&gt;L5&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;white-space: nowrap;&quot;&gt;End-to-end autonomous&lt;/td&gt;
      &lt;td&gt;Sets objectives, quality bars, risk thresholds, escalation rules; reviews exceptions only&lt;/td&gt;
      &lt;td&gt;Detects anomalies and opportunities, runs the loop, surfaces insight, evolves the metric layer, context and skills&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Human judgement remains at every level, and autonomy never removes accountability. Humans own problem framing, canonical metric definitions, the causal story behind a move, business-case assumptions, the go/no-go, and the stakeholder relationship. A higher level means more of the mechanical loop sits with the agent and more human attention concentrates on the ambiguous, high-stakes work.&lt;/p&gt;

&lt;h2 id=&quot;making-the-climb&quot;&gt;Making the climb&lt;/h2&gt;

&lt;p&gt;Five core capabilities move a workflow up the ladder. They also gate the climb in order: L3 needs execution and certified context, L4 needs gates and agentic review good enough that reviewing only at gates is honest, L5 needs a learning loop that closes.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Execution:&lt;/strong&gt; A stack that runs the loop end to end rather than a notebook/workflow a human drives.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Knowledge:&lt;/strong&gt; Metrics certified at the right grain, discoverable in our catalogue, grounded in context an agent can read. Ambiguous definitions cause most analytics slop.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Control:&lt;/strong&gt; Repeatable expectations become mechanical checks, while human review handles what a rule cannot.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Review and governance:&lt;/strong&gt; Agents check their own output against the gates and escalate on defined triggers. We govern definitions, targets, risk and exceptions.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Learning:&lt;/strong&gt; When an agent fails the same way twice, we encode the fix into context documents, golden datasets, evals and gates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-this-looks-like-in-practice&quot;&gt;What this looks like in practice&lt;/h2&gt;

&lt;p&gt;What follows is a set of explorations from the last two years. Some run in production today, while others are still teaching us where the limits are.&lt;/p&gt;

&lt;h3 id=&quot;loops-that-run-end-to-end&quot;&gt;Loops that run end to end&lt;/h3&gt;

&lt;p&gt;Spartan is our end-to-end agentic analytics workflow, embedded across surface areas (like Slack), and most of its usage comes from people who are not analysts. On any given day, the Slack channel enables a range of analytics actions: from ads salespeople pulling spend breakdowns for a named merchant, to campaign managers sizing audiences for a target segment, and country teams asking why a number moved week on week. All of it in plain business language.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Index architecture across our knowledge base.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Two requests from July best demonstrate how it works. A commercial manager asked why revenue fell in the Philippines mid-market segment in the last two weeks of June. Separately, a product manager asked for a summary of a frequency-cap experiment on the ads surface. Both arrived as natural language questions in Slack and took entirely different routes through the system.&lt;/p&gt;

&lt;p&gt;The router reads the first as a root-cause question and sends it down the diagnostic path. It identifies the best analysis framework for ads revenue, which is codified knowledge of how the metrics in that domain relate to each other, which dimensions are worth decomposing, and what counts as a meaningful move. Then it works through segment, market and campaign type against certified metrics to isolate what changed. The second question never touches the data lake. The router reads it as an experiment question, selects the experiment skill, pulls the pre-computed scorecard and the test’s own metadata from our experiment platform, and summarises the read rather than recomputing it. This is powered through &lt;strong&gt;50+ skills and 120+ analysis frameworks&lt;/strong&gt; that sit behind that routing decision. Underlying that is an index that tells the agent what to search, context that tells it how to query, and a framework that tells it how to think. Because the frameworks are shared rather than living in an analyst’s head, the interpretation compounds instead of being re-derived every time someone asks.&lt;/p&gt;

&lt;p&gt;The second example of such a loop is Scarlet, which powers near-self-healing pipelines (L4). When a pipeline fails, an agent runs the root-cause analysis, triages, and then either fixes it or hands it to the team that owns the upstream problem. It escalates when the failure sits outside its documented runbooks or the pre-defined gates fire.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-2.png&quot; alt=&quot;&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2. Scarlet in action on Slack.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;context-that-maintains-itself&quot;&gt;Context that maintains itself&lt;/h3&gt;

&lt;p&gt;Context sets an agent’s ceiling. An agent that does not know a metric’s grain, its exclusions, and its caveats will guess and confidently produce wrong outputs at speed and at scale.&lt;/p&gt;

&lt;p&gt;Realising the criticality of this, we have dedicated platform investment, as well as dedicated functional bandwidth to generate context docs.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-3.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3. ContextIQ.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Context goes out of date faster than anyone maintains it by hand, so we build the maintenance into our workflows. We built ContextIQ, and its Context Lifecycle Manager, to treat context as something with a lifecycle rather than a document somebody wrote once. A newer skill of ours reads an instrumentation spec alongside the existing context, proposes the SQL changes that follow from it, and updates the context document in the same pass. We work the problem from the other direction too. When we categorise an agent failure in production, we patch the context document behind it.&lt;/p&gt;

&lt;p&gt;Two analysts recently used our internal agents to understand how the packaging fee is stored as a configuration. Having found the answer, the agent opened a merge request that committed both a certified-context table reference and a golden-dataset test case, so the next agent to ask the same question would find the answer already documented and the check already in place. One of the analysts spotted a false positive in it. The agent corrected itself and reopened the merge request. That is the learning capability working as designed, and it happened without anyone setting out to demonstrate it.&lt;/p&gt;

&lt;h3 id=&quot;loops-that-run-unattended&quot;&gt;Loops that run unattended&lt;/h3&gt;

&lt;p&gt;The step from L3 to L4 is mostly the step from interactive to scheduled, and it is where we go down the path of autonomous execution, because no human is watching at the moment the work runs.&lt;/p&gt;

&lt;p&gt;We have built root-cause analysis (RCA) as a platform capability, and it powers our automated metric and OKR commentaries, which are published through automated agents &lt;em&gt;(configurable cadence)&lt;/em&gt;. It judges whether a move is meaningful against standard deviation over six months and year on year, walks the metric tree to find which country, segment or funnel stage carried it, and correlates the operational metrics that moved alongside. Importantly, it also scans internal context for what teams changed on the ground, such as delivery fee and incentive moves, merchant visibility shifts, and experiments shipped in the same period. It also compares the movement against the same period in the previous year, which separates a seasonal effect from a real one and enables it to report a Songkran (Thai New Year) dip as amplified rather than merely expected. All of it is grounded in our own context documents, which keeps the narrative about the business rather than generic model output. The analytics owner is tagged on every report, and edits sync back so corrections land in the system.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-4.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 4. OKR commentary shared through RCA agent.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;analysts-as-builders&quot;&gt;Analysts as builders&lt;/h3&gt;

&lt;p&gt;The clearest evidence that our centre of gravity has moved is &lt;a href=&quot;https://engineering.grab.com/brix&quot;&gt;BriX&lt;/a&gt;, an internal portal we built and run ourselves.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-5.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 5. Home page of BriX.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The premise is to configure once, host everywhere. We configure a system prompt, a set of context files, a model, the MCP connections and an interface once, and what comes out is a purpose-built analytics surface for a particular team or job. Each one inherits certified data, permissions and reusable agent skills rather than being wired up from scratch, and it runs wherever the work already happens: in Slack, invoked from inside an IDE, or on a schedule with nobody watching. We have grown usage more than 10x since September 2025, with strong retention, and every function at Grab now has users on it. Our aim is to put L3 workflows in the hands of people who are not advanced users.&lt;/p&gt;

&lt;p&gt;We run it without a product manager, a technical programme manager or a designer. Our data engineers own the product, the platform, the support queue and the eval loop, with Claude Design doing the interface work and the builders triaging their own bugs. In the first half of this year, they shipped 31 production deployments, 283 merge requests and 60 features.&lt;/p&gt;

&lt;p&gt;Three of our apps show the range:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Insights Lab&lt;/strong&gt; is the general-purpose surface: a stakeholder asks for a metric, a breakdown or a root-cause in natural language, and the agent loads a specialist skill and answers off certified metrics rather than from memory.&lt;/li&gt;
  &lt;li&gt;We built &lt;strong&gt;Funnelytics&lt;/strong&gt; to enable easy understanding of our consumer funnels. A funnel question used to mean an analyst writing the query and then assembling the view in Tableau or Power BI, and doing it again the next time someone wanted a slightly different path through the app. Now a stakeholder picks the events they care about and Funnelytics queries the raw event stream, builds the Sankey and funnel views, and writes the summary. If they cannot find the right instrumentation, which happens often on products still being redesigned, a live debugger lets them tap through the app on their own phone and watch the events fire.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Monte&lt;/strong&gt; (like Monte Carlo) runs simulations to put a probability on a business outcome. You give each uncertain input a range rather than a single value, and it runs ten thousand scenarios to return the likelihood of hitting a target.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-6.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 6. Interface of Insights Lab and Funnelytics.
  &lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Outside the portal, the same instinct shows up in smaller ways. Our analysts have been building more bespoke tools that enable better workflows for themselves and stakeholders.&lt;/p&gt;

&lt;h2 id=&quot;the-path-forward&quot;&gt;The path forward&lt;/h2&gt;

&lt;p&gt;In February, &lt;strong&gt;44%&lt;/strong&gt; of the tickets our analysts closed were mechanical (data preparation, alerting, reporting); by June, that share had fallen to &lt;strong&gt;30%&lt;/strong&gt;. That capacity was redirected to other higher-leverage work, such as building new workflows to enable stakeholder self-serve, as well as more time spent on generating deeper insights for business opportunities.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-7.png&quot; alt=&quot;&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 7. Comparison of percentage of tickets closed in Q1 vs Q2.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Importantly, our cycle times reduced by &lt;strong&gt;~33%&lt;/strong&gt;.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/ai-improve-analytics/figure-8.png&quot; alt=&quot;&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 8. Comparison of time taken to resolve a ticket in Q1 vs Q2.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The sharpest version of this sits in a Slack channel where self-serve agents are enabled. In March, an analyst had to step into half of them; by May, it was under a quarter. The share answered with no human involvement rose from 53% to 67% for metric questions, 63% to 90% for data pulls, and 50% to 81% for SQL requests. Just under three in four of the threads were started by someone outside the analytics team, and 85% of them got a first response inside a minute. Nearly every thread is logged as a ticket on the team’s board, and roughly two-thirds of the data exploration tickets on that board now arrive through the channel rather than through an analyst, and are solved by our data agents. &lt;strong&gt;For the ~230 tickets that arrived via the channel, if we apply a conservative assumption of 1–2 days per ticket, that is 230 to 470 business days of stakeholder asks that would have been in the backlog.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of these arrived on a roadmap. They came from analysts who saw a loop worth automating and built it, which is why the climb is uneven. These have been strong proof points for us to believe our investments are working, and many of these workflows are starting to operate at scale. We will keep experimenting and iterating, and we expect to get a fair amount of it wrong. An analyst who owns a loop, sets its quality bar and reviews its exceptions is doing a different job from one who answers questions. Most of our team is somewhere in that transition today, and we truly believe it is changing what analytics is at Grab.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is a leading superapp in Southeast Asia, operating across the deliveries, mobility, and digital financial services sectors. Serving over 900 cities in eight Southeast Asian countries: Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam. Grab enables millions of people every day to order food or groceries, send packages, hail a ride or taxi, pay for online purchases or access services such as lending and insurance, all through a single app. We operate supermarkets in Malaysia under Jaya Grocer and Everrise, which enables us to bring the convenience of on-demand grocery delivery to more consumers in the country. As part of our financial services offerings, we also provide digital banking services through GXS Bank in Singapore and GXBank in Malaysia. Grab was founded in 2012 with the mission to drive Southeast Asia forward by creating economic empowerment for everyone. Grab strives to serve a triple bottom line. We aim to simultaneously deliver financial performance for our shareholders and have a positive social impact, which includes economic empowerment for millions of people in the region, while mitigating our environmental footprint.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grab.careers/&quot;&gt;join our team today&lt;/a&gt;!&lt;/p&gt;

&lt;p&gt;&lt;small class=&quot;credits&quot;&gt;
Special thanks to the contributors: &lt;br /&gt;A3T Braintrust - Maanas Prabhakar, Sneh Agarwal, Nikhil Dwarakanath, Chun Kai Phang, Raghav Garg, Avishek Ghosh, Ankush Khandelwal, Nishant Gupta, Harshita Shah, Lara Pureum Yim, Alex Dobrev&lt;br /&gt;Spar/tan 3.0 - Lara Pureum Yim, Kenrick Tan, Tushar Bose, Yong Kai Lim, Dandy Kennedy, Glen Low, Zian Li&lt;br /&gt;BriX - Sneh Agarwal, Rishi Raj, Kartik Srinivasan, Aseem Kumar, Eddy Lim, Wen Zhong Tan &lt;br /&gt; Funnelytics - Chengxuan Feng, Wingyiu Ng, Zoey Zhou&lt;br /&gt;ContextIQ - Defi Mediana, Karen Koh, Anitha Sreedhar, Wanyi Zhu&lt;br /&gt;Butler Bot - Adhika Putra&lt;br /&gt;Monte - Nikhil Dwarakanath
&lt;/small&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 01 Aug 2026 00:23:00 +0000</pubDate>
        <link>https://engineering.grab.com/how-ai-is-transforming-analytics</link>
        <guid isPermaLink="true">https://engineering.grab.com/how-ai-is-transforming-analytics</guid>
        
        <category>Engineering</category>
        
        <category>Analytics</category>
        
        <category>AI</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Crowdsourced taxonomy verification: A feedback-driven framework for refining knowledge graph relationships via online search interactions</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;The efficacy of semantic search relies on the accuracy of the underlying Knowledge Graph (KG). In high-velocity domains like on-demand food delivery or e-commerce, the catalog of entities like dishes, products, and merchants changes rapidly.&lt;/p&gt;

&lt;p&gt;Current methods for KG construction and maintenance face three critical challenges:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Inaccuracy and hallucination from Large Language Models (LLMs)&lt;/strong&gt;: Automated models often infer relationships based on statistical text co-occurrence rather than semantic reality. For instance, an LLM might incorrectly classify “Pho” as a child of “Italian Noodle Soup” due to linguistic similarity, leading to irrelevant search results.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Scalability limits of manual verification&lt;/strong&gt;: Traditional verification relies on human annotators or domain experts. This approach is slow, expensive, and unable to keep pace with dynamic catalogs containing millions of entities. For example, daily changes in restaurant menus or grocery stock keeping units (SKUs).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Error propagation in ranking&lt;/strong&gt;: Inaccurate graph edges propagate errors downstream. If a parent-child relationship is wrong, query expansion algorithms will retrieve irrelevant items, directly degrading Click-Through Rate (CTR) and user trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We introduce a &lt;strong&gt;feedback-driven verification engine&lt;/strong&gt; that operationalizes the search interface as a validation environment. Key contributions include:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;User feedback-driven verification&lt;/strong&gt;: The system treats unverified graph edges as hypotheses. Instead of accepting them as truth, it tests them against live traffic by injecting them into search suggestions and measuring user engagement.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Hierarchical relationship refinement&lt;/strong&gt;: Unlike systems that only validate entities (nodes), this framework validates structural links (edges). It confirms whether entity A is truly a parent, child, or sibling of entity B, ensuring structural integrity.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Adaptive exploration&lt;/strong&gt;: The system employs a greedy exploration policy. It intelligently balances exploitation by showing known good results with exploration through injecting unverified candidates to gather data without degrading the user experience.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;Automated KG construction using LLMs and unstructured content extraction can scale quickly across large, dynamic catalogs. However, relationships inferred from text co-occurrence or vector similarity do not always reflect semantic reality. Manual verification by domain experts remains accurate but does not scale to millions of entities that change daily.&lt;/p&gt;

&lt;p&gt;When inaccurate edges enter the graph, ranking and query expansion systems propagate those errors to users. Incorrect parent-child or sibling links lead to irrelevant search results, reduced CTR, and lower user trust. An additional solution is required that can validate graph structure continuously, at scale, without relying solely on manual curation.&lt;/p&gt;

&lt;h2 id=&quot;solution&quot;&gt;Solution&lt;/h2&gt;

&lt;p&gt;The overall workflow of this invention is shown in the following figure. The details of each step are explained in this section.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/crowdsource-taxonomy/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. The system architecture.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The proposed framework functions as a closed-loop validation ecosystem. It is composed of four integrated modules designed to continuously cycle data from the KG to the user interface and back, using real-world interactions to separate semantic truth from artificial intelligence (AI) hallucinations.&lt;/p&gt;

&lt;p&gt;The verification process follows a continuous, iterative loop that cycles data from the backend graph to the frontend user interface and back. This four-step procedure operationalizes the human-in-the-loop validation mechanism:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Hypothesis generation&lt;/li&gt;
  &lt;li&gt;Candidate injection&lt;/li&gt;
  &lt;li&gt;Signal aggregation and scoring&lt;/li&gt;
  &lt;li&gt;Graph update logic&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;architecture-details&quot;&gt;Architecture details&lt;/h2&gt;

&lt;h3 id=&quot;kg-core&quot;&gt;KG core&lt;/h3&gt;

&lt;p&gt;The central repository acts as the source of truth, storing entities such as dishes, products, or merchants, and the connections between them. To manage the verification process, the system introduces a specialized metadata layer that classifies every connection (or edge) into one of two distinct states:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Verified edges:&lt;/strong&gt; These are established relationships that have been validated either by high historical traffic or human confirmation. They represent the safe structure of the graph. For example, “Sushi” is definitely a child of “Japanese Cuisine”, and is used to power standard search results.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Candidate edges:&lt;/strong&gt; These are probabilistic, unverified relationships generated by automated LLMs or content scrapers. They are treated as hypotheses waiting to be proven. For example, if an LLM ingests a blog post and predicts that “Pho” is related to “Italian Noodle Soup,” this link is stored as a candidate edge, invisible to the main search algorithm until validated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;search-and-injection-module&quot;&gt;Search and injection module&lt;/h3&gt;

&lt;p&gt;This module sits between the KG and the user, intercepting the query execution pipeline. Unlike standard ranking algorithms, which strictly optimize for relevance by showing only the best results, the injection engine employs a balanced strategy known as exploration vs. exploitation.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The injection mechanism:&lt;/strong&gt; When a user performs a search, the system retrieves a list of high-confidence results (exploitation). Simultaneously, it deliberately retrieves a small subset of candidate edges related to the query. It injects these unverified candidates into specific, lower-risk slots within the user interface, such as the third or fourth position in a related searches chip carousel.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Risk management:&lt;/strong&gt; To prevent user frustration, the system limits the number of candidates shown per session. This ensures that the user is primarily served helpful, verified content, while still providing enough data points to test new hypotheses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;behavior-tracking-module&quot;&gt;Behavior tracking module&lt;/h3&gt;

&lt;p&gt;To accurately measure whether a candidate relationship is valid, the system tracks user micro-interactions with high granularity. It captures not just the final click, but the precise context in which the interaction occurred to determine semantic intent.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Contextual anchoring&lt;/strong&gt;: The system logs the specific search term, also known as the &lt;strong&gt;anchor&lt;/strong&gt;, used by the user. A click on “Pho” is only counted as a vote for the relationship if the user was searching for “Noodle Soup” at the time.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Signal classification&lt;/strong&gt;: Signals are assessed in aggregate to estimate the relevance of a candidate relationship. Higher-intent engagement contributes stronger positive evidence, lighter exploratory behavior contributes weaker positive evidence, and lack of engagement or explicit negative actions contributes negative evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;verification-and-refinement-engine&quot;&gt;Verification and refinement engine&lt;/h3&gt;

&lt;p&gt;This is an offline processing unit that acts as the final judge. It aggregates thousands of individual user signals to update the topology of the KG.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relevance scoring&lt;/strong&gt;: Instead of complex formulas, the engine calculates a simple confidence ratio. It looks at the total number of times a candidate was shown versus the number of positive interactions it received.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graph topology updates&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Promotion (verify)&lt;/strong&gt;: If the confidence ratio exceeds a verification threshold. For example, if the candidate performs as well as known good items, the edge is upgraded from candidate to verified. It becomes a permanent part of the graph and is shown to all users.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Demotion (prune)&lt;/strong&gt;: If the candidate consistently fails to garner engagement or receives negative signals, it falls below a pruning threshold. The system automatically deletes this edge, effectively correcting the AI’s hallucination and cleaning the dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;

&lt;h3 id=&quot;hypothesis-generation&quot;&gt;Hypothesis generation&lt;/h3&gt;

&lt;p&gt;The process begins by identifying a target subject, referred to as the anchor entity. For example, the specific dish “Pho”. The system queries the KG to retrieve a set of potential relationships. This retrieval includes both verified neighbors, where relationships are already confirmed by experts, and candidate neighbors, where the relationships are predicted by AI models but not yet proven.&lt;/p&gt;

&lt;h3 id=&quot;candidate-injection&quot;&gt;Candidate injection&lt;/h3&gt;

&lt;p&gt;Once a hypothesis is selected, the system exposes it to real users to gather evidence. When a user actively searches for the anchor entity, the system dynamically injects the candidate neighbor into the search results.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;User interface (UI) implementation:&lt;/strong&gt; The candidate is presented alongside verified items, typically in a related categories carousel or a refine search chip list. This reflects standard relevance experimentation in search, with safeguards to ensure the experience remains controlled and measurable.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Exposure logging:&lt;/strong&gt; The system logs an impression event specifically linking the anchor to the candidate. This record serves as the baseline, documenting that the user saw the relationship, which is essential for calculating future engagement rates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;signal-aggregation-and-scoring&quot;&gt;Signal aggregation and scoring&lt;/h3&gt;

&lt;p&gt;Instead of using a raw count of clicks, the system calculates a sophisticated relationship confidence score by aggregating user interactions over time. This scoring model uses a weighted tier system to distinguish between casual interest and strong intent.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Weighted interaction logic&lt;/strong&gt;: The system assigns a higher value to actions that require more effort or commitment. For example, a “Purchase” or “Add-to-Cart” action is weighted significantly heavier than a simple click, as it indicates a strong validation of the relationship. Conversely, scrolling past the item quickly or skipping is treated as a negative signal.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Normalization&lt;/strong&gt;: To ensure fairness, the total weighted score is normalized against the total number of times the candidate was shown. This prevents niche items with low total traffic but high accuracy from being unfairly penalized.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;graph-update-logic&quot;&gt;Graph update logic&lt;/h4&gt;

&lt;p&gt;Periodically, the verification engine evaluates the confidence score against predefined benchmarks to update the KG’s topology. This is a binary decision process:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Validation (cementing the edge)&lt;/strong&gt;: If the accumulated confidence score exceeds a strict validation threshold, the system concludes that the relationship is genuine. The status of the edge is updated from candidate to verified. This permanently adds the relationship to the graph, ensuring it appears in future standard searches without the need for further testing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Rejection (pruning the edge)&lt;/strong&gt;: Conversely, if the score falls below a rejection threshold, indicating that users consistently ignore or reject the suggestion, the system concludes the relationship is an AI hallucination. The edge is severed or removed from the graph. This pruning action cleans the dataset, preventing the system from making the same bad recommendation again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;case-study-hierarchical-refinement-in-food-delivery&quot;&gt;Case study: hierarchical refinement in food delivery&lt;/h4&gt;

&lt;p&gt;To demonstrate the framework, consider a validation scenario in food delivery taxonomy. An LLM-based ingestion pipeline flags a candidate parent-child link&lt;br /&gt;
&lt;strong&gt;Noodle Soup → Dry Mee Pok&lt;/strong&gt; and stores it as an unverified candidate edge in the KG, ready for live validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User-triggered validation&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;When a user searches for “Noodle Soup,” the search module injects the candidate alongside verified results. For example, in a “Refine by Dish” filter carousel, and logs an impression linking the anchor query to the candidate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outcome collection&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;User interactions like clicks, dwell time, scroll behavior, and conversions are captured and weighted over the validation window. The verification engine aggregates these signals and updates the graph: relationships that meet the validation threshold are promoted to verified status; those that fail are pruned or re-mapped to a more appropriate parent node.&lt;/p&gt;

&lt;h2 id=&quot;impact&quot;&gt;Impact&lt;/h2&gt;

&lt;p&gt;By injecting unverified candidate edges into live search results and recommendation interfaces via a multi-armed bandit (MAB) exploration strategy, the system leverages implicit user feedback to validate semantic truth. This dynamic, human-in-the-loop mechanism effectively prunes erroneous connections and reinforces accurate taxonomies without the need for manual curation, significantly enhancing search relevance in dynamic domains such as food delivery and retail.&lt;/p&gt;

&lt;p&gt;The case study demonstrates how the framework validates candidate relationships through live user traffic, collecting interaction signals and updating the graph without manual curation.&lt;/p&gt;

&lt;h2 id=&quot;learnings-and-conclusion&quot;&gt;Learnings and conclusion&lt;/h2&gt;

&lt;p&gt;The feedback-driven verification engine operationalizes the search interface as a validation environment for KG relationships. By classifying edges as verified or candidate, injecting candidates through an exploration vs. exploitation strategy, and aggregating weighted user signals, the system promotes accurate relationships and prunes AI hallucinations at scale.&lt;/p&gt;

&lt;p&gt;Unlike approaches that validate only entities, this framework validates structural links, confirming whether entity A is truly a parent, child, or sibling of entity B. The food delivery case study shows how a user-triggered search can initiate validation and outcome collection at scale, without manual intervention.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hierarchical confidence tiers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To safely graduate new connections into the production graph, we are introducing a dual-measurement trust system that requires both volume and variety before a new connection goes live: support mass (product hits, graph depth, recency) and corroboration (unique sessions, anonymous cohorts, and temporal spread). Connections must climb a strict state machine: &lt;strong&gt;proposed → shadow eligible → canary eligible → production&lt;/strong&gt;, advancing only when both metrics meet progressively higher thresholds; if a snapshot causes metrics to fall below a tier’s floor, the connection is automatically demoted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adversarial and spam resistance&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To prevent bad actors, bots, or highly repetitive users from manipulating the search graph, we are building a multi-layered defense system. We enforce per-merchant rate limits and anti‑abuse controls: hourly caps per session/device, exponential backoff for rapidly repeated actions, and a short (few‑hour) freeze of promotions from any user cohort after declines or “irrelevant” signals. For bot and Sybil attack defense, traffic flagged by abuse systems is excluded from trust calculations (but logged for analysis); votes must come from diverse network subnets or cohort buckets, and each bucket is subject to a daily contribution cap.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is a leading superapp in Southeast Asia, operating across the deliveries, mobility, and digital financial services sectors. Serving over 900 cities in eight Southeast Asian countries: Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam. Grab enables millions of people every day to order food or groceries, send packages, hail a ride or taxi, pay for online purchases or access services such as lending and insurance, all through a single app. We operate supermarkets in Malaysia under Jaya Grocer and Everrise, which enables us to bring the convenience of on-demand grocery delivery to more consumers in the country. As part of our financial services offerings, we also provide digital banking services through GXS Bank in Singapore and GXBank in Malaysia. Grab was founded in 2012 with the mission to drive Southeast Asia forward by creating economic empowerment for everyone. Grab strives to serve a triple bottom line. We aim to simultaneously deliver financial performance for our shareholders and have a positive social impact, which includes economic empowerment for millions of people in the region, while mitigating our environmental footprint.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grab.careers/&quot;&gt;join our team today&lt;/a&gt;!&lt;/p&gt;
</description>
        <pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/crowdsourced-taxonomy-verification</link>
        <guid isPermaLink="true">https://engineering.grab.com/crowdsourced-taxonomy-verification</guid>
        
        <category>Engineering</category>
        
        <category>Data</category>
        
        <category>Search</category>
        
        <category>Machine Learning</category>
        
        <category>LLM</category>
        
        <category>Graphs</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Agent platform (Part 1): How we help Grab build and run AI agents at scale</title>
        <description>&lt;h2 id=&quot;part-1-from-one-support-bot-to-a-framework&quot;&gt;Part 1: From one support bot to a framework&lt;/h2&gt;

&lt;p&gt;At Grab, AI agents have evolved from interesting team prototypes into production services used every day by millions of merchants, drivers, and consumers. Today, more than 500 services run on our internal agent framework, over 50 Model Context Protocol (MCP) servers are registered on our remote MCP framework, and a single Large Language Model (LLM) gateway fronts every model call across the company, handling billions of tokens each month.&lt;/p&gt;

&lt;p&gt;None of this was designed up front. It began as the plumbing behind &lt;strong&gt;one&lt;/strong&gt; internal support bot, which then expanded because the same problems kept resurfacing for every team trying to ship an agent. This series tells the story of what the platform eventually became. This Part 1 of the blog focuses on the beginning: the architecture of our AI support bot, the specific pain points we hit while scaling and iterating on it, and how each of those failures became a core building block in the framework we now call &lt;strong&gt;LLM-Kit&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-bot-that-started-it&quot;&gt;The bot that started it&lt;/h2&gt;

&lt;p&gt;Imagine you have a question for the &lt;strong&gt;Technical Infrastructure (Tech Infra)&lt;/strong&gt; team - the engineers who run the cloud platforms, databases, developer tooling, and AI infrastructure behind Grab’s ecosystem. Instead of immediately paging an on-call engineer, a bot first triages the request, checks the team’s documentation, runbooks, and past Slack threads, and tries to answer directly in the thread. If it still cannot resolve the issue, it routes the ticket to the right human, with the relevant context already attached.&lt;/p&gt;

&lt;p&gt;That is what we built with the Tech Infra Support Bot.&lt;/p&gt;

&lt;p&gt;In the first half of 2023, Tech Infra handled thousands of support tickets, many of them repeated questions that had already been answered somewhere internally. Before LLMs, the bot’s role was mainly operational; performing tasks like helping track acknowledgments and response times for on-call engineers. With the arrival of GPT-4-32k, we evolved it into a GPT-powered Level-0 support layer that could answer documented questions before a human needed to be paged.&lt;/p&gt;

&lt;p&gt;The first production version was a Go service organized around two planes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;A reasoning plane&lt;/strong&gt;. At Level-0, it was a single-agent loop. It takes the user’s question, decides which tools to call, executes those calls, feeds the results back into the prompt, and returns an answer. The default model at the time was gpt-4.1; today, we have evolved to the latest reasoning models.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;A tool plane&lt;/strong&gt;. The tools provided the bot’s core working context. Retrieval flowed through Glean, which covered Confluence, 
&lt;a href=&quot;https://engineering.grab.com/techdocs-at-grab-cultivating-a-culture-of-quality-documentation&quot;&gt;TechDocs&lt;/a&gt;, internal drives, and Jira. Other tools handled log search through Kibana, GitLab runbook and file access, Slack conversation search, and a small set of Hypertext Transfer Protocol (HTTP) plugins. In the first version, tools and prompts were defined in per-channel JavaScript Object Notation (JSON) configs and resolved at request time. As models became more capable, we later standardized the tool set across channels.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trimmed version of that tool config looked like this:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nl&quot;&gt;&quot;agent_plugins&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;glean_search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;metadata&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;wiki_space_collection&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]}},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;runbook_search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;gitlab_runbook_reader&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;gitlab_read_file&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;kibana_log_search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;metadata&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;k8s*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;slack_conversation_tool&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;common&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It worked, but it taught us, the hard way, why a demo agent is not a production agent.&lt;/p&gt;

&lt;h2 id=&quot;what-it-takes-to-scale-and-improve-quickly&quot;&gt;What it takes to scale and improve quickly&lt;/h2&gt;

&lt;p&gt;As we worked on improving the agent, we kept running into the same kinds of friction. Over time, those pain points formed clear patterns, and they were the same ones we saw other teams run into as well.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Vibe check is not an evaluation strategy&lt;/strong&gt;. The bot had a base prompt, and each Slack channel could configure its own prompt, tools, and documentation filters. But the workflow was essentially: configure it, ship it, and hope it reduced toil. There were no real evaluations, just optimism that it would work.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fast model and provider switching is essential&lt;/strong&gt;. The AI landscape moves incredibly fast: a new state-of-the-art (SOTA) model appears on Tuesday, and a highly efficient open-source alternative shows up on Thursday. Switching providers should not feel like open-heart surgery. A unified Software Development Kit (SDK) and an &lt;a href=&quot;https://engineering.grab.com/grab-ai-gateway&quot;&gt;LLM API gateway&lt;/a&gt; remove the need to refactor payload schemas, rewrite error handling, or integrate each provider from scratch. If moving from OpenAI to Anthropic, or routing to an open-source model endpoint, takes more than a few config changes, technical debt is already slowing you down.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Observability cannot be an afterthought&lt;/strong&gt;. When an answer was wrong, figuring out “why” meant grepping logs across three separate systems: the agent workflow, the tool calls, and the model call. There was no shared trace tying them together. That level of friction is survivable for an internal tool; it is unacceptable for a customer-facing agent.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Everything around the agent took longer than the agent itself&lt;/strong&gt;. Auth (OIDC), secrets management (Vault), per-environment config, vector database integration, LLM tracing, health probes, and metrics were not agent-specific problems. However, they all had to be solved before anything could be shipped. The reasoning loop took a whole afternoon. The production wrapper took two weeks.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern was clear: the hard part of building an agent was not the agent itself, but everything around it that had to be in place before it could safely run in front of users. So we began pulling those shared components out of the bot and consolidating them into a unified framework.&lt;/p&gt;

&lt;h2 id=&quot;extracting-the-framework-llm-kit&quot;&gt;Extracting the framework: LLM-Kit&lt;/h2&gt;

&lt;p&gt;LLM-Kit emerged when we stopped solving these problems service by service and started solving them once, centrally. It is intentionally not a new agent abstraction or a Domain-Specific Language (DSL). Instead, it is a curated set of integrations and scaffolding built around Grab’s existing infrastructure, pipelines, secret management, and observability. Just as importantly, we chose to build a framework rather than a heavy centralized platform. In a space evolving this quickly, a platform would have locked teams into rigid assumptions that would soon become outdated. A framework let us meet developers where they already were: standardizing the plumbing while preserving the freedom to iterate quickly. Looking back, that was the right first choice. Each part of LLM-Kit is a direct response to one of the failures described above.&lt;/p&gt;

&lt;p&gt;We first wrote about LLM-Kit’s structure and code architecture in a &lt;a href=&quot;https://engineering.grab.com/supercharging-llm-application-development-with-llm-kit&quot;&gt;2024 blog post&lt;/a&gt;. Two years and a few hundred agents later, the overall &lt;em&gt;shape&lt;/em&gt; is still recognizable, but almost every underlying layer has changed. Poetry was replaced by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv&lt;/code&gt;; we standardized on the OpenTelemetry stack; LangChain evolved into LangGraph and Deep Agents; and some tools moved onto our MCP framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It starts with a template&lt;/strong&gt;. The entry point is a user interface (UI) form. An engineer fills in an application name and a few details, and gets back a GitLab repository with the production wrapper already assembled. Under the hood the template stamps out a full FastAPI service:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/
├── app/
│   ├── server.py              # FastAPI app factory: mounts routes + middleware, boots OTel + statsd
│   ├── agents/
│   │   ├── simple_react_agent.py   # a single-agent LangGraph ReAct loop (agent &amp;lt;-&amp;gt; tools)
│   │   ├── mcp_react_agent.py      # the same loop, but tools are pulled from remote MCP servers
│   │   └── simple_react_agent.png  # auto-exported graph diagram (generated in dev)
│   ├── routes/
│   │   ├── api.py             # router aggregator
│   │   ├── health_check.py    # liveness/readiness probe
│   │   ├── oidc.py            # OIDC login/callback (skipped in proxy-auth mode)
│   │   └── evalshub_eval.py   # runs ROUGE / BLEU / LLM-as-judge evals on the agent
│   ├── core/config.py         # AppConfig (pydantic-settings) + INI/secret parsing
│   ├── tools/word_length_tool.py   # an example tool to copy from
│   ├── utils/prompts.py       # prompt/message assembly helpers
│   └── storage/connection.py  # Postgres + pgvector engine and connection pooling
├── sdk/         # a generated, typed client SDK (protobuf) other services import
├── configs/
│   ├── dev.ini / stg.ini / prd.ini   # one config per environment
│   └── secret.ini.example     # secret template; real values resolve from Vault at deploy
├── databases/postgresql/      # SQL migrations (pgvector extension bootstrapped for you)
├── scripts/
│   ├── db.py / db.sh          # migration runner
│   └── gunicorn_conf.py       # production server/worker config
├── tests/
│   ├── unit_tests/            # starter unit tests (e.g. the health check)
│   └── evalshub_evaluation/   # golden test cases the eval route runs against
├── Dockerfile                 # multi-stage, distroless
├── Makefile                   # setup / run / test / lint targets
├── pyproject.toml             # uv build backend + pinned deps
└── .pre-commit-config.yaml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Three things are worth pulling out of that tree:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/agents/&lt;/code&gt; is the part you actually own&lt;/strong&gt;. You get two working agents to fork from rather than a blank file: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simple_react_agent.py&lt;/code&gt; is a single-agent LangGraph ReAct loop, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mcp_react_agent.py&lt;/code&gt; is the same loop wired to pull its tools from remote MCP servers. Both compile to a LangGraph &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StateGraph&lt;/code&gt; with a retry policy and a 30-second per-step timeout, and in dev the graph is auto-exported as a diagram. This is a real step up from the bare LangChain agent initialization we scaffolded in 2024.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/routes/evalshub_eval.py&lt;/code&gt; ships evals on day one&lt;/strong&gt;. The template comes with an endpoint that runs Recall-Oriented Understudy for Gisting Evaluation (ROUGE), Bilingual Evaluation Understudy (BLEU), and LLM-as-judge evaluators over a set of golden test cases in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tests/evalshub_evaluation/&lt;/code&gt;. The thing we most wished the support bot had, is now in the box before a builder writes a line of their own logic.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Everything else is the production wrapper&lt;/strong&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;core/config.py&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;storage/&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;configs/&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;databases/&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scripts/&lt;/code&gt;, the distroless &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dockerfile&lt;/code&gt;, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyproject.toml&lt;/code&gt; (now &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv&lt;/code&gt;, not the Poetry we used in 2024) are the auth, secrets, persistence, packaging, and deploy plumbing that every service needs and that no team should have to write from scratch.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The day-one wiring that used to take two weeks or more now takes about an hour. The rest of this section is what “pre-wired” means, layer by layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config and secrets are solved once&lt;/strong&gt;. Apps declare environment configs as initialization (INI) files with secret interpolation, so secrets resolve from &lt;a href=&quot;https://www.hashicorp.com/en/products/vault?utm_source=google&amp;amp;utm_channel_bucket=paid&amp;amp;utm_medium=sem&amp;amp;utm_campaign=core_apac_multi_eng_x_all_sem-gg_x_all_all_all_br&amp;amp;utm_content=hashicorp%20vault-190017550690-Brand_Vault_SLM-802491325140&amp;amp;gad_source=1&amp;amp;gad_campaignid=23699376650&amp;amp;gbraid=0AAAAAC15ru8puHsQth55S3GvC2N6lMusm&amp;amp;gclid=CjwKCAjwgO7RBhBKEiwAZNP85mSwHd8Tvwt_6i6a869Bwuqqws3tcto-4Fb4c3XHD-LL2Um0fARKyxoCrVgQAvD_BwE&quot;&gt;Vault&lt;/a&gt; at boot, and a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secret.ini.example&lt;/code&gt; is enough to run any LLM-Kit app locally:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[CONFIG]
GRABGPT_API_KEY=${SECRET:GRABGPT_API_KEY}
OTEL_EXPORTER_OTLP_ENDPOINT=&amp;lt;otel-collector-endpoint&amp;gt;
POSTGRES_POOL_RECYCLE=1800
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Model access behind one resolver&lt;/strong&gt;. Every model call goes through the GrabGPT Gateway, which is OpenAI-compatible. LLM-Kit’s job is just to resolve the right endpoint (per environment, and per data tier) and inject the key so application code never hard-codes a provider again:&lt;/p&gt;

&lt;div class=&quot;language-py highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;openai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OpenAI&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;llm_kit.grabgpt&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resolve_grabgpt_base_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resolve_grabgpt_api_key&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;OpenAI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;base_url&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resolve_grabgpt_base_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;prd&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# provider chosen centrally
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;resolve_grabgpt_api_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That one indirection is what later lets a platform team change which provider serves a model, configure fallback routing, set budgets, and manage cost attribution, without a single application touching its code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing wired in, not bolted on&lt;/strong&gt;. A single instrumentor auto-instruments FastAPI, outbound HTTP, LangChain, and MCP, and stamps every span with Kubernetes resource attributes (pod, namespace, image, service version). Structured logs auto-inject the trace and span IDs, so logs and traces correlate in Grafana/Kibana for free:&lt;/p&gt;

&lt;div class=&quot;language-py highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;exporter&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;OTLPSpanExporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;app_config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;otel_exporter_otlp_endpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nc&quot;&gt;OTELInstrumentor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exporter&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;excluded_urls&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;health_check&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;instrument_app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;three systems, no shared trace&lt;/strong&gt; problem turns into &lt;strong&gt;one end-to-end trace&lt;/strong&gt; across every LLM call, tool call, and retrieval step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools can be exposed through MCP servers built on our MCP framework&lt;/strong&gt;. Instead of hardwiring a large set of tool functions inside the agent process, the agent connects to MCP servers and discovers their tools at runtime. That means adding a new capability can be as simple as registering an MCP server, rather than redeploying the agent.&lt;/p&gt;

&lt;div class=&quot;language-py highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;MultiServerMCPClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;mcp-gitlab-remote&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;transport&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;streamable_http&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;lt;remote-mcp-gitlab-endpoint&amp;gt;/mcp/&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Authorization&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Bearer &amp;lt;token&amp;gt;&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;tools&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;get_tools&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# schema negotiated, no redeploy
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;An agent is just another service in the ecosystem, with gRPC on both sides. Most of Grab’s backend communicates over gRPC, and agents are rarely standalone; other services call them, and they in turn call other internal services. The template is designed to support both directions.&lt;/p&gt;

&lt;p&gt;On the &lt;em&gt;serving&lt;/em&gt; side, the scaffold includes a Protocol Buffers (protobuf) contract (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sdk/.../.proto&lt;/code&gt;, with a sample &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Hello&lt;/code&gt; remote procedure call (RPC)) and a generated, typed client SDK package that other teams &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import&lt;/code&gt; to call your agent without hand-writing HTTP. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;make gen-proto&lt;/code&gt; regenerates the Python stubs from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.proto&lt;/code&gt;, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen-proto-check&lt;/code&gt; Continuous Integration (CI) step fails the build if the committed stubs drift from the contract. A gRPC server runs alongside FastAPI (default port &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8087&lt;/code&gt;, multi-worker-safe via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SO_REUSEPORT&lt;/code&gt;) and ships a standard gRPC health service out of the box:&lt;/p&gt;

&lt;div class=&quot;language-shell highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;grpcurl &lt;span class=&quot;nt&quot;&gt;-plaintext&lt;/span&gt; localhost:8087 grpc.health.v1.Health/Check
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;On the &lt;em&gt;calling&lt;/em&gt; side, LLM-Kit ships a channel provider so an agent never hardcodes an address. The auto provider tries Istio, then Consul, then a static fallback, health-checks the channel it selects, and runs a background monitor that re-selects after a few consecutive failures:&lt;/p&gt;

&lt;div class=&quot;language-py highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;llm_kit.grpc.channel_providers.auto&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;AutoGrpcChannelProvider&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AutoGrpcChannelProviderConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;provider&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;AutoGrpcChannelProvider&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;AutoGrpcChannelProviderConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;my-agent&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;service_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;some-internal-service&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# resolved via Istio / Consul
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;enable_istio&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;enable_consul&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;channel&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;get_channel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;           &lt;span class=&quot;c1&quot;&gt;# first healthy channel, auto-reselected on failure
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stub&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SomeServiceStub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is the less glamorous side of being &lt;strong&gt;production-ready&lt;/strong&gt;. Before an agent can deliver value, it needs to both accept calls from and make calls to the rest of the company’s services using the same transport the broader system already relies on.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;

&lt;p&gt;LLM-Kit solved &lt;em&gt;building and shipping&lt;/em&gt; one agent. At 500 agents, the problems were no longer framework problems. They were platform problems: who can change which model everyone calls, how one team safely reuses another team’s tools, and how you know an agent got &lt;em&gt;better&lt;/em&gt; and not just &lt;em&gt;different&lt;/em&gt; after a prompt change. We built three answers for that layer: the GrabGPT Gateway, a remote MCP framework, and an evals platform. Part 2 starts with the gateway — one endpoint, five providers, and what it takes to make “swap the model” a configuration change instead of an incident.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://www.grab.careers/en/&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Fri, 24 Jul 2026 00:00:10 +0000</pubDate>
        <link>https://engineering.grab.com/how-grab-builds-and-runs-ai-agents-at-scale</link>
        <guid isPermaLink="true">https://engineering.grab.com/how-grab-builds-and-runs-ai-agents-at-scale</guid>
        
        <category>Engineering</category>
        
        <category>Generative AI</category>
        
        <category>LLM</category>
        
        <category>Experiment</category>
        
        <category>Machine Learning</category>
        
        
        <category>Engineering</category>
        
        <category>Design</category>
        
      </item>
    
      <item>
        <title>Scaling Grab&apos;s Data Lake: Our journey to Apache Iceberg adoption</title>
        <description>&lt;h2 id=&quot;introduction-the-evolution-of-grabs-data-lake&quot;&gt;Introduction: The evolution of Grab’s Data Lake&lt;/h2&gt;

&lt;p&gt;At Grab’s scale, managing petabytes of data across billions of S3 objects demands more than a storage layer. It demands a robust architectural primitive that supports the high-concurrency needs of a modern “Lakehouse.” Our goal is full storage-compute separation, leveraging S3 as an elastic foundation for both near-real-time metrics and large-scale batch transformations.&lt;/p&gt;

&lt;p&gt;For years, the vast majority of our tables were Hive Parquet, managed through the Hive Metastore with a directory-based layout. This model served us well, but as data volume grew, the directory-and-metastore approach became the limiting factor. We are now transitioning to a table-centric architecture built on modern table formats, treating data as a first-class primitive to ensure consistency and performance across our internal data transformation platforms: Slide, which powers batch transformations, and Hugo, which handles online-to-data-lake ingestion. Along the way, we also built the UnifiedSparkCatalog, a unified Spark catalog that hides table-format differences from users entirely, which we are open-sourcing alongside this post.&lt;/p&gt;

&lt;h2 id=&quot;the-catalyst-for-change-challenges-with-hive-parquet&quot;&gt;The catalyst for change: Challenges with Hive Parquet&lt;/h2&gt;

&lt;p&gt;For years, Hive Parquet was the backbone of our Data Lake, representing the vast majority of our tables. However, as data volume scaled, the architectural limitations of directory-based storage became apparent. We identified four primary bottlenecks:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Catalog latency:&lt;/strong&gt; The Hive Metastore (HMS) became a centralized failure point. High concurrency during metadata access led to O(n) listing overhead, where query planning time scaled linearly with partition count, crippling throughput.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The small file problem:&lt;/strong&gt; The directory layout left us with severe file fragmentation. Certain Machine Learning (ML) datasets had an average file size under 1 MB, with thousands of files in each partition. At this scale, the overhead of S3 object listing and metadata request latency drove up Application Programming Interface (API) costs and slowed scan operations.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Operational toil:&lt;/strong&gt; Data engineers faced constant manual overhead for partition registration. Without native ACID support (no native &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UPSERT&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DELETE&lt;/code&gt;), teams relied on complex workarounds to manage data changes carefully.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The broken information loop:&lt;/strong&gt; A fundamental disconnect existed between the catalog and storage. Because the HMS, not the storage layer, was treated as the source of truth, direct S3 modifications frequently left the catalog stale and out of sync with the actual state on disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;why-iceberg-strategic-alignment-and-future-proofing&quot;&gt;Why Iceberg? Strategic alignment and future-proofing&lt;/h2&gt;

&lt;p&gt;We evaluated several open table formats before selecting Apache Iceberg as our default. The deciding factors came down to community governance, engine compatibility, and long-term flexibility.&lt;/p&gt;

&lt;p&gt;Recent industry momentum, including growing cloud-native support for Iceberg, further validates this direction. We are positioning Grab to be format-agnostic in the long term, but Iceberg provides the most mature foundation today.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/journey-to-apache-iceberg/table-1.png&quot; alt=&quot;Comparison of Legacy Hive Parquet and Apache Iceberg&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h2 id=&quot;adopting-iceberg-at-scale&quot;&gt;Adopting Iceberg at scale&lt;/h2&gt;

&lt;p&gt;Migrating an established lake is not a flag flip. Our challenge was rolling out Iceberg across a lake that was overwhelmingly Hive Parquet, queried by many engines and teams, without breaking the downstream consumers that depended on those tables. Rather than converting everything at once, we moved the highest-value tables first. The efficiency gains across our production workloads have been substantial. Here are representative examples:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Query performance via Z-ordering:&lt;/strong&gt; On a high-traffic navigation dataset, we achieved roughly a &lt;strong&gt;10x improvement in query runtime&lt;/strong&gt;. Z-ordering co-locates rows with similar values across specified dimensions, enabling Trino to leverage data skipping and min/max statistics to prune irrelevant files during query planning. This reduced query runtime from 70 seconds to 6 seconds.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;S3 API cost reduction:&lt;/strong&gt; For a heavily queried operations table, daily S3 API costs were reduced by up to 95% with no changes to the queries themselves. Larger file sizes and the elimination of expensive object listing during query planning drove most of the savings.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Compute savings:&lt;/strong&gt; For a dataset used in funnel analysis, we reduced cluster resource usage by approximately half. A separate ML feature pipeline also improved feature freshness for downstream models.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-unifiedsparkcatalog-making-mixed-formats-transparent&quot;&gt;The UnifiedSparkCatalog: Making mixed formats transparent&lt;/h2&gt;

&lt;p&gt;Migrating to Iceberg solved our storage and metadata problems, but it surfaced a new one at the developer-experience layer. Modern table formats like Delta, Iceberg, and Hudi each implement their own custom catalog that extends Spark’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SessionCatalog&lt;/code&gt;. In a standard Spark runtime, only one catalog implementation can be set as the default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spark_catalog&lt;/code&gt;. Supporting additional formats requires explicit catalog declarations, meaning users must reference tables with format-specific prefixes like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iceberg_catalog.schema.table&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delta_catalog.schema.table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With Iceberg, Delta, Hudi, and Hive tables now coexisting and tables actively migrating between formats, this created two problems: engineers had to know the underlying format of every table they queried, and any format migration silently broke every downstream query that hardcoded a prefix.&lt;/p&gt;

&lt;p&gt;The UnifiedSparkCatalog is our answer. It is a unified Spark catalog that abstracts the complexity of working with mixed table formats so users never need to think about which format a table uses. We took inspiration from Trino’s Table Redirection, a feature that transparently points a query at the right connector when a table’s format differs from the catalog it was queried through. Our Spark equivalent works as follows:&lt;/p&gt;

&lt;h3 id=&quot;how-it-works&quot;&gt;How it works&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Table detection:&lt;/strong&gt; The catalog loads metadata from the Hive Metastore.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Format identification:&lt;/strong&gt; A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TableTypeDetector&lt;/code&gt; utility identifies the format based on metadata properties (e.g., the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;provider&lt;/code&gt; field) or path-based inference.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Operation routing:&lt;/strong&gt; The catalog delegates the operation to the correct format-specific catalog (Iceberg’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SparkCatalog&lt;/code&gt;, Delta’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeltaCatalog&lt;/code&gt;, etc.) without requiring any prefix from the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;key-design-decisions&quot;&gt;Key design decisions&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Lazy initialization:&lt;/strong&gt; Catalogs for each format are initialized only when first needed, reducing startup overhead. If a format’s JAR is missing from the classpath, initialization continues gracefully. The catalog simply skips that format rather than failing the entire session.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Naming as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spark_catalog&lt;/code&gt;:&lt;/strong&gt; The catalog reports its name as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spark_catalog&lt;/code&gt; because Spark treats this name specially for legacy Hive Data Manipulation Language (DML) operations. Many internal Spark code paths check for this exact name to determine whether to use Hive-compatible logic for inserts, updates, and deletes. Using any other name would break legacy Hive table operations.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Catalog reuse:&lt;/strong&gt; Before creating a new catalog instance, the system checks whether one already exists in Spark’s catalog manager. This preserves compatibility with plugins like OpenLineage, which inspect catalog class types for lineage extraction.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fallback behavior:&lt;/strong&gt; If a table is not found in the expected format-specific catalog, the system falls back to the base session catalog, ensuring robust behavior for standard Hive tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We are open-sourcing UnifiedSparkCatalog alongside this blog post. The code and documentation are available &lt;a href=&quot;https://github.com/grab/unified-spark-catalog&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;lessons-learned-and-overcoming-hurdles&quot;&gt;Lessons learned and overcoming hurdles&lt;/h2&gt;

&lt;p&gt;Scaling Iceberg across a large ecosystem revealed several technical nuances:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Hive lock contention:&lt;/strong&gt; We encountered “zombie locks” in the HMS that blocked commits. We traced this to a low read timeout on the metastore side under high load. Adjusting retry intervals and increasing the timeout resolved the issue.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Timestamp handling:&lt;/strong&gt; Spark 3.4 introduced &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TIMESTAMP_NTZ&lt;/code&gt; (no time zone), while Iceberg defaults to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TIMESTAMP_LTZ&lt;/code&gt; (local time zone). This caused compatibility issues with legacy Hive views. We resolved it through a custom migration workflow and targeted patches to our Trino deployment to ensure consistent casting.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Storage tier costs:&lt;/strong&gt; Generating Iceberg metadata involves reading historical data, which can trigger a one-time cost spike as files move between S3 storage tiers. To manage this, we prioritize migrations based on a table’s scan frequency and API operation costs rather than migrating the entire lake at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion-the-road-ahead&quot;&gt;Conclusion: The road ahead&lt;/h2&gt;

&lt;p&gt;Apache Iceberg is now foundational to Grab’s data strategy. It is the default format for Slide and Hugo, and adoption is expanding across our compute platforms.&lt;/p&gt;

&lt;p&gt;Looking forward, we are experimenting with &lt;strong&gt;Storage Partitioned Joins&lt;/strong&gt; to eliminate shuffle stages in Spark and monitoring the &lt;strong&gt;Apache XTable&lt;/strong&gt; project to maintain interoperability between formats. Our journey does not end with adoption. We will continue contributing back to the ecosystem, starting with the upcoming release of the UnifiedSparkCatalog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acknowledgments:&lt;/strong&gt; This journey was made possible by the dedicated efforts of the Data Engineering, Infrastructure, and Search &amp;amp; Personalization teams at Grab.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://www.grab.careers/en/&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/our-journey-to-apache-iceberg-adoption</link>
        <guid isPermaLink="true">https://engineering.grab.com/our-journey-to-apache-iceberg-adoption</guid>
        
        <category>Data</category>
        
        <category>Database</category>
        
        <category>Engineering</category>
        
        <category>Apache Iceberg</category>
        
        <category>Spark</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Migrating Counter Service storage: Design choices and learnings</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://engineering.grab.com/using-grabs-trust-counter-service-to-detect-fraud-successfully&quot;&gt;Counter Service&lt;/a&gt; is used across Grab’s anti-fraud platform to answer time-windowed count questions, such as recent ride requests by a user or failed payment attempts on a card. The service handles tens of thousands of queries per second (QPS) with about a billion requests per day, while maintaining strict requirements around latency and reliability to support real-time fraud rule evaluation.&lt;/p&gt;

&lt;p&gt;For most of its life, Counter Service was backed by a wide-column database that served the workload reliably as the service scaled. As part of a broader infrastructure review mandated at an organizational level, our database team evaluated alternatives to this storage that many services relied on, including Counter Service. Based on their assessment, Aerospike emerged as a good fit for our use-case. We also used the migration as an opportunity to decouple storage concerns from business logic, a necessary first step for this migration, and one that would reduce the effort required for future storage changes. As part of the same effort, we revisited the data model and access patterns in detail, which helped us identify and apply several straightforward optimizations.&lt;/p&gt;

&lt;p&gt;This post walks through how we did it. What we built on the reader-side to make the migration safe, how we redesigned the writer-side data model around the new backend, and what we ran into during the gradual rollout.&lt;/p&gt;

&lt;h2 id=&quot;setting-the-stage&quot;&gt;Setting the stage&lt;/h2&gt;

&lt;p&gt;Counter data is stored in three time granularities: 15-minute, hourly, and daily buckets. A typical read would be along the lines of, “give me the count for key X over the last 90 minutes”, which the service decomposes into the smallest possible set of buckets, one hourly in the middle, a few 15-minute buckets at the edges, fetches them, and sums.&lt;/p&gt;

&lt;p&gt;In the original setup, each granularity was stored in a separate table with a composite primary key:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;daily_count&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;key&lt;/span&gt;      &lt;span class=&quot;nb&quot;&gt;TEXT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;         &lt;span class=&quot;c1&quot;&gt;-- partition key&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;day_ts&lt;/span&gt;   &lt;span class=&quot;nb&quot;&gt;TIMESTAMP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;    &lt;span class=&quot;c1&quot;&gt;-- clustering key&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;count&lt;/span&gt;    &lt;span class=&quot;nb&quot;&gt;BIGINT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;day_ts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The clustering column gave us convenient range queries, that is needed for the Counter Service. On the write path, each incoming counter event triggered a read-modify-write, three parallel &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT&lt;/code&gt; across the three tables, an in-memory increment, then a batch write. This produced four network round-trips per event.&lt;/p&gt;

&lt;p&gt;As this service is a core part of Grab’s fraud detection ecosystem and handles high query volume, migrating its underlying storage required a careful rollout plan. We had three requirements:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Ramp traffic to the new backend gradually and roll back at any point with a config change.&lt;/li&gt;
  &lt;li&gt;Monitor both the original and new storage paths to verify data integrity before switching over.&lt;/li&gt;
  &lt;li&gt;Complete the migration without downtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also wanted the migration machinery to be reusable for future storage changes. The migration is divided into three workstreams, which we’ll walk through below:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Preparing the reader service.&lt;/li&gt;
  &lt;li&gt;Identifying the best integration mechanism for the new storage.&lt;/li&gt;
  &lt;li&gt;Updating the writer pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;reader-separating-the-data-access-layer&quot;&gt;Reader: Separating the data access layer&lt;/h2&gt;

&lt;p&gt;The reader is a Rust service. Before any migration work began, the reader’s business logic had tight coupling with the storage layer. Session creation, query building, fan-out orchestration, and the data types those queries returned were all intertwined in a single flat file. The main application state struct (AppState) held a raw database session handle and prepared query references. Every handler, gRPC Remote Procedure Calls (gRPC) or HyperText Transfer Protocol (HTTP), received the bare session as a parameter. Variable names baked the storage technology into the business layer.&lt;/p&gt;

&lt;p&gt;This made the storage migration difficult to attempt directly. We couldn’t add a second storage backend without forking the orchestration logic, and we had no way to test the read path in isolation from a real database session. So we did the migration prep in three stages.&lt;/p&gt;

&lt;h3 id=&quot;stage-1-extracting-the-storage-code&quot;&gt;Stage 1: Extracting the storage code&lt;/h3&gt;

&lt;p&gt;The first stage shipped no behavioural change. We deleted the monolithic storage file and split its contents in two:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;storage/legacy.rs&lt;/code&gt;: wrapped session creation, prepared statements, and query execution behind a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LegacyStorage&lt;/code&gt; struct.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;batch_read_ops.rs&lt;/code&gt;: kept only the orchestration logic: time-range splitting, channel-based fan-out, and aggregation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AppState&lt;/code&gt; started holding an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Arc&amp;lt;LegacyStorage&amp;gt;&lt;/code&gt; instead of a raw session handle. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PreparedQueries&lt;/code&gt; struct lost its statements (those moved inside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LegacyStorage&lt;/code&gt;). We renamed every storage-specific identifier in business code to generic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;storage_*&lt;/code&gt; names.&lt;/p&gt;

&lt;p&gt;The result was a hard fence. After Stage 1, the database driver crate was reachable only from inside the storage module. Nothing in the business logic or handlers imported it any more.&lt;/p&gt;

&lt;h3 id=&quot;stage-2-the-storage-facade&quot;&gt;Stage 2: The storage facade&lt;/h3&gt;

&lt;p&gt;With the seam in place, we introduced the actual abstraction. A new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;storage/&lt;/code&gt; module with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mod.rs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;legacy.rs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aerospike.rs&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mock_storage.rs&lt;/code&gt; as siblings became the only place driver crates were reachable from.&lt;/p&gt;

&lt;p&gt;The idiomatic Rust approach would have been a trait with associated types, but our backend selection is runtime (a config string parsed at startup), and associated types propagate upwards through every consumer. The alternative, trait objects with boxed futures adds a heap allocation per query, which we wanted to avoid at our QPS.&lt;/p&gt;

&lt;p&gt;We chose a concrete facade with enum dispatch:&lt;/p&gt;

&lt;div class=&quot;language-rust highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Storage&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;legacy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;LegacyStorage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;aerospike&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AerospikeStorage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;mock&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;      &lt;span class=&quot;n&quot;&gt;MockStorage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;n&quot;&gt;StorageSettings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nf&quot;&gt;execute_queries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;backend&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BackendType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;match&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;backend&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Legacy&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;.legacy&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Aerospike&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;.aerospike&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Mock&lt;/span&gt;      &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;.mock&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A match statement at the request boundary, which made it easier to reason about and debug. The facade then routes everything to the original backend without the rest of the code knowing or caring.&lt;/p&gt;

&lt;p&gt;Each backend’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;execute_queries&lt;/code&gt; honours the same contract: take a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vec&amp;lt;QueryCandidate&amp;gt;&lt;/code&gt; and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HashMap&amp;lt;BatchIndex, Sender&amp;lt;...&amp;gt;&amp;gt;&lt;/code&gt;, and emit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(index, value, timestamp, granularity)&lt;/code&gt; tuples into those channels. The orchestration layer above doesn’t need to know whether a candidate became a paginated row stream or a single batch read with client-side map filtering, both write into the same channels in the same shape.&lt;/p&gt;

&lt;p&gt;On top of the facade we layered three config-driven operating modes that map to the migration phases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Single&lt;/strong&gt;: one backend serves the request.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;WithShadow&lt;/strong&gt;: the primary serves the response; the secondary runs asynchronously in the background for parity comparison.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;WithSplit&lt;/strong&gt;: a deterministic percentage of traffic is served by each backend. Used for the live cutover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mode and traffic percentages are read from a service config, allowing the reader to move from legacy-only to Aerospike-only without code changes. The transition starts in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Single(legacy)&lt;/code&gt;, then shadow reads are enabled with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WithShadow(primary=legacy, secondary=aerospike, pct=X)&lt;/code&gt;. The shadow percentage is gradually ramped from 5% to 20%, 50%, and finally 100%, while parity is verified through metrics. Optionally, the system can then move into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WithSplit(primary=legacy, secondary=aerospike, split=X)&lt;/code&gt;, where live traffic is gradually shifted from the original backend to Aerospike, for example from 5% to 30%, 70%, and then 100%. Once Aerospike is fully validated and serving all traffic, the reader moves to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Single(aerospike)&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;stage-3-shadow-comparison-and-metrics&quot;&gt;Stage 3: Shadow comparison and metrics&lt;/h3&gt;

&lt;p&gt;Each storage call carries metadata like backend, role (primary/secondary/shadow), and mode, attached as tags to every metric. When Aerospike was added, existing dashboards showed per-backend breakdowns without changes.&lt;/p&gt;

&lt;p&gt;We placed the mode dispatch at the handler level rather than inside the storage layer to validate the full request path, not only the rows returned by storage. This also lets the response return as soon as the primary completes, while the shadow runs as a fire-and-forget background task.&lt;/p&gt;

&lt;h2 id=&quot;writer-redesigning-the-data-model&quot;&gt;Writer: redesigning the data model&lt;/h2&gt;

&lt;p&gt;Since the two systems use different storage engines, it wasn’t clear that a one-to-one port of our original schema would work. We tried three approaches.&lt;/p&gt;

&lt;h3 id=&quot;approaches-1-and-2-row-per-bucket&quot;&gt;Approaches 1 and 2: Row-per-bucket&lt;/h3&gt;

&lt;p&gt;We first tried mirroring our original row-per-bucket model. Approach 1 used Aerospike’s Secondary Index (SI) to recover range queries; approach 2 skipped SI and computed the exact set of primary keys client-side via BatchGet.&lt;/p&gt;

&lt;p&gt;Both hit the same wall: Aerospike’s primary index is 64 bytes per record, kept in memory. At billions of records, index memory becomes the constraint. SI added overhead and operational complexity we didn’t need.&lt;/p&gt;

&lt;h3 id=&quot;approach-3-map-based-schema&quot;&gt;Approach 3: Map-based schema&lt;/h3&gt;

&lt;p&gt;The third approach was structurally different from the first two and was the most compact of the options. Rather than storing one record per bucket, which kept us in the same cardinality regime, we collapsed all bucket counts for a single counter into one record. The values were stored as a sorted map keyed by bucket timestamp:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Set:           helium_hourly
Primary key:   &quot;{counterKey}&quot;
Bins:
   counts: KEY_ORDERED_MAP({
        1773369000000: 1,
        1773372600000: 3,
        1773376200000: 7,
        ...
   })
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The map keys are bucket timestamps in milliseconds. The map values are running counts. One record holds the entire time series for one counter at one granularity.&lt;/p&gt;

&lt;p&gt;Reads become straightforward: fetch the record, iterate the map, sum the entries within the requested window. Each Get returns a bounded number of map entries (determined by Time To Live (TTL) and bucket size), and client-side filtering of that many entries is negligible.&lt;/p&gt;

&lt;p&gt;Writes use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MapIncrementOp&lt;/code&gt;, an atomic server-side increment of a value at a given map key, creating the entry on first access. Combined with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MapRemoveByKeyRangeOp&lt;/code&gt; for pruning stale entries, every write is one atomic operation:&lt;/p&gt;

&lt;div class=&quot;language-py highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;ops&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;MapIncrementOp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;counts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bucketTsMs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;MapRemoveByKeyRangeOp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;counts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cutoffMs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;PutOp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key_bin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;counterKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Operate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ops&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For TTL management, we couldn’t use Aerospike’s record-level expiry directly. A single record holds many timestamps, so record-level TTL would either keep everything or drop everything. Instead, we prune stale map entries explicitly on every write using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MapRemoveByKeyRangeOp&lt;/code&gt;. The record-level TTL stays as a safety net for counters that stop receiving writes.&lt;/p&gt;

&lt;p&gt;The two backends produce very different network shapes for the same logical query. The original backend returns many small paginated row streams, one per (key, granularity). The server filters by time range using the clustering column. Aerospike returns one batch response with the entire counts map per key, and the client filters the map to the requested range. The reader’s storage layer hides this difference: both paths emit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(index, value, timestamp, granularity)&lt;/code&gt; tuples into the same per-index channels, and the orchestrator above sums them the same way.&lt;/p&gt;

&lt;p&gt;The third approach performed best in testing. By collapsing many bucket records into a single record per counter, we reduced the total record count by more than an order of magnitude, which also reduced primary index memory. It also produced a smaller on-disk footprint, since the long counter key is stored once per record instead of being repeated across every bucket. The schema was chosen to fit the access pattern, with the index and disk savings following naturally.&lt;/p&gt;

&lt;p&gt;The pipeline continues writing to the original backend as the primary, while Aerospike is added as a separate asynchronous shadow write behind a deterministic rollout logic. This lets us ramp Aerospike gradually and eventually cut over to it fully.&lt;/p&gt;

&lt;h2 id=&quot;reader-how-each-backend-actually-serves-a-query&quot;&gt;Reader: How each backend actually serves a query&lt;/h2&gt;

&lt;p&gt;The two storage backends sit behind the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;execute_queries&lt;/code&gt; contract on the reader service, but what they do internally for a single batch read looks very different.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/migrating-counter-service/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. How a single read request flows through each backend.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The reader takes a batch of counter queries and decomposes each into one or more sub-queries per granularity (a 90-minute window for instance, becomes one hourly sub-query and two 15-minute sub-queries). In the original backend, each sub-query becomes its own prepared statement bound with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(start_ms, end_ms, key)&lt;/code&gt;, and the storage layer fires all of them concurrently as a stream of futures with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;buffer_unordered&lt;/code&gt; capping in-flight queries to a tuned bound. Each query returns a paginated row iterator, the server uses the clustering column to filter by time range and rows stream through to per-index channels as they arrive. So a single user request can produce many small queries, each a separate network round-trip to the partition master holding key, with results dribbled back over a paginated stream.&lt;/p&gt;

&lt;p&gt;On Aerospike, the storage layer first groups all sub-queries by granularity, then issues one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BatchOperate&lt;/code&gt; per granularity. Each sub-query becomes a single primary-key read against the appropriate set; the server returns the entire counts map for that key in one record. The client iterates the map and emits only the entries whose timestamps fall inside the requested range. This keeps the code simple, and at our map sizes the overhead is negligible. There’s no streaming, a batch read either succeeds or fails as a unit and there are at most three network round-trips per user request, one per granularity, regardless of how many sub-queries there are.&lt;/p&gt;

&lt;p&gt;This reflects the different design philosophies of the two systems. Wide-column stores typically expect client-side fan-out for reads, while Aerospike’s batch API is designed for exactly this multi-key pattern.&lt;/p&gt;

&lt;p&gt;A few issues with the Aerospike Rust client also surfaced during rollout, as it was less mature than its Go counterpart. For example, when we started, the officially available Rust client was synchronous, so every batch read had to be bridged through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tokio::task::spawn_blocking&lt;/code&gt; with some amount of custom plumbing. Once the official async client was released, we removed that layer and saw measurable improvements in both p50 and p99 latency. The other issue was Domain Name System (DNS). The client resolved seed hostnames only during initialization and did not re-resolve them when the cluster topology refreshed. As a result, a full staging cluster replacement, with new IPs behind the same hostnames, left the client stuck on the old IPs until restart. We filed the bug upstream, and a fix shipped in a subsequent release. We also reproduced the scenario locally with a Docker-based end-to-end test and ran additional staging drills to confirm recovery before continuing the rollout.&lt;/p&gt;

&lt;h2 id=&quot;experiment-with-indexing&quot;&gt;Experiment with indexing&lt;/h2&gt;

&lt;p&gt;We run Aerospike in its default storage configuration, Hybrid Memory Architecture (HMA), where the primary index sits in Random-Access Memory (RAM) and the data sits on Solid-State Drive (SSD). The other relevant mode keeps both index and data in Dynamic Random-Access Memory (DRAM), which is more expensive and not something that fits our use-case. Even in HMA, the primary index grows linearly with record count. At our scale, that growth was a foreseeable issue.&lt;/p&gt;

&lt;p&gt;To raise the memory ceiling, we tried moving the primary index itself from RAM to local Non-Volatile Memory Express (NVMe) while keeping data on SSD. We expected the extra index latency to be invisible within our overall request budget. In practice, we started seeing p99 spikes that did not track overall QPS. Instead, they followed I/O activity on hot keys. We observed that when many concurrent lookups land on the same record, the in-memory index handles them more prudently compared to a disk backed index. Adding more and better nodes improved things slightly but did not mitigate the issue. Consequently, we reverted back to in-memory index with a memory-optimized instance type.&lt;/p&gt;

&lt;h2 id=&quot;overall-impact&quot;&gt;Overall impact&lt;/h2&gt;

&lt;p&gt;The migration delivered gains across infrastructure, performance, and data footprint. Most of these improvements trace back to the schema redesign like collapsing rows into maps, rather than the database change itself.&lt;/p&gt;

&lt;p&gt;The primary index currently uses about 50 GB of the roughly 100 GB usable memory per node. The same dataset is around 1 TB on disk, compared with around 3 TB on the original setup. This is primarily attributed to our adoption of the map-based schema discussed earlier.&lt;/p&gt;

&lt;p&gt;In production, p99 read latency was consistently better than the original setup, with roughly 50% improvement across our read paths. The write path now uses a single atomic increment operation, replacing the read-modify-write pattern we had built previously.&lt;/p&gt;

&lt;p&gt;The new setup costs roughly 45–50% less per node compared to our original setup. We also reduced the replication factor from 3 to 2, saving roughly a third of both storage and primary index memory. RF=2 can be awkward in databases that depend on write quorum, but Aerospike’s master-replica model still keeps an authoritative copy available after a single-node loss. That gives us meaningful fault tolerance even at RF=2. The remaining risk, a simultaneous multi-AZ failure, was acceptable for this workload because the writer continues producing increments from the source event stream. Any lost counter data can self-heal as new events arrive.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;This migration ultimately came down to aligning the storage design with the workload. These results would not have been achieved by simply swapping one storage system for another. As the service evolved over time, our initial design choices became less optimal, and the migration surfaced opportunities to rethink them. The gains came from focusing on optimization opportunities, redesigning the data model, and cleanly separating storage concerns. Through shadow reads and writes, followed by a gradual rollout, we completed the migration with zero downtime and no data-integrity issues. The result is a system that fits its workload well and a foundation that makes future storage changes safer and easier to attempt.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebcounterservice&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/counter-service-storage-migration</link>
        <guid isPermaLink="true">https://engineering.grab.com/counter-service-storage-migration</guid>
        
        <category>Security</category>
        
        <category>Artificial Intelligence</category>
        
        <category>Kubernetes</category>
        
        <category>DevSecOps</category>
        
        <category>Platform</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Scaling out Distroless adoption With AI</title>
        <description>&lt;h2 id=&quot;distroless-adoption-at-grab&quot;&gt;Distroless adoption at Grab&lt;/h2&gt;

&lt;p&gt;Grab is migrating from heavy base images to Distroless images to reduce security risks. By limiting each container to the application and its runtime dependencies, we shed non-essential binaries and associated Common Vulnerabilities and Exposures (CVEs).&lt;/p&gt;

&lt;p&gt;This migration is more than a compliance mandate; it is a strategic security decision to build a more resilient environment.&lt;/p&gt;

&lt;h2 id=&quot;why-distroless-requires-rigorous-testing&quot;&gt;Why Distroless requires rigorous testing&lt;/h2&gt;

&lt;h3 id=&quot;distroless-adoption-risk-runtime-failure&quot;&gt;Distroless adoption risk: Runtime failure&lt;/h3&gt;

&lt;p&gt;Shifting to Distroless images introduces a critical technical risk: &lt;strong&gt;Runtime Failure&lt;/strong&gt;. A service might build perfectly in Continuous Integration (CI), but fail at the deployment stage due to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Missing shared objects&lt;/strong&gt;: Binaries might require specific libraries (&lt;strong&gt;.so&lt;/strong&gt; files) present in Ubuntu but absent in Distroless.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Implicit links&lt;/strong&gt;: Third-party tools might expect specific system utilities or directory structures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing is required to ensure two things:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;The service spins up with the correct configuration.&lt;/li&gt;
  &lt;li&gt;All runtime dependencies remain intact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scaling this verification across thousands of services manually? That would take years, unless we found a way to automate the trust.&lt;/p&gt;

&lt;h2 id=&quot;the-testing-methodology&quot;&gt;The testing methodology&lt;/h2&gt;

&lt;p&gt;As we perform changes to the Dockerfile definition of our services, it is important for us to include the corresponding test strategy to ensure that the changes that we make do not introduce a regression to our running services. Assessing the change introduced to our services, the lowest possible testing boundary would be that of what we define as medium tests in Grab.&lt;/p&gt;

&lt;h3 id=&quot;medium-tests-in-grab&quot;&gt;Medium tests in Grab&lt;/h3&gt;

&lt;p&gt;At Grab, we categorize our test suites into 3 main sizes: small, medium and large. Small tests refer to functional tests whereby mocks are introduced via dependency injection. Large tests refer to end-to-end tests that run on actual services in our staging environment where nothing is mocked.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-1.png&quot; alt=&quot;Architecture Diagram of a Medium Test Environment.&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1: Architecture diagram of a medium test environment&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Medium tests sit between the small and large tests. External dependencies (such as service-to-service dependencies) are mocked with a network proxy layer similar to &lt;a href=&quot;https://wiremock.org/&quot;&gt;WireMock&lt;/a&gt;, while internal dependencies like MySQL are left unmocked and are started instead with &lt;a href=&quot;https://testcontainers.com/&quot;&gt;Testcontainers&lt;/a&gt;. The system under test is built into a Docker image, run as a container, and exercised through its endpoints; the tests then assert on the responses. That setup lets us catch Dockerfile changes that would break the service in production. A further benefit is that the whole flow can run inside Continuous Integration (CI) so that problems surface before anything reaches Continuous Deployment (CD).&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-2.png&quot; alt=&quot;Happy Path for Distroless Changes.&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2: Happy path for Distroless changes&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;This makes medium tests effective and efficient for testing changes to the services associated with Distroless adoption. We could now largely scale up our adoption process by:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Raising batch Merge Requests to dockerfile definitions for Distroless adoption.&lt;/li&gt;
  &lt;li&gt;Running medium tests in CI.&lt;/li&gt;
  &lt;li&gt;Upon passing the medium tests, automatically merge the changes and trigger CD.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;introduction-of-toil&quot;&gt;Introduction of toil&lt;/h2&gt;

&lt;p&gt;The above methodology works well for services that already have medium tests. However, we soon ran into a blocker when rolling it out to services that do not yet have a medium test setup. Inherently, scaffolding medium tests for a service is a tedious task. Most of the toil comes from first identifying internal dependencies, then bringing up the corresponding test containers during tests, and then connecting those dependencies to the service under test by updating the test environment configuration.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-3.png&quot; alt=&quot;Current Gap.&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3: Current gap&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;These tasks are not challenging but are generally tedious to set up. They also cannot be fully automated because every service combines internal dependencies differently, and because configuration is defined and used in different ways across codebases. Roughly 400 services in scope still lack a medium test setup, which became a major obstacle to our Distroless migration campaign.&lt;/p&gt;

&lt;p&gt;Since each step needs flexible execution and is only moderately complex, artificial intelligence (AI) is a natural way to accelerate Distroless adoption.&lt;/p&gt;

&lt;h2 id=&quot;ai-the-toil-buster&quot;&gt;AI: The toil buster&lt;/h2&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-4.png&quot; alt=&quot;Solution Leveraging AI.&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 4: Solution leveraging AI&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;AI was a good fit because the work we needed to automate produced a clear deliverable and we could tell in a deterministic way whether it had succeeded. Success was straightforward: the CI pipeline would turn green after running the basic health-check medium tests. With a measurable end goal and a reliable success signal, we pursued an agentic workflow rather than a one-off generation attempt.&lt;/p&gt;

&lt;h3 id=&quot;the-starting-point&quot;&gt;The starting point&lt;/h3&gt;

&lt;p&gt;We started by adopting &lt;a href=&quot;https://code.claude.com/docs/en/skills&quot;&gt;skills&lt;/a&gt; to guide the agent on how to carry out the medium-test work and how to get past repo-specific blockers. These skills gave context for scaffolding basic medium tests, setting up internal dependencies, and debugging issues in the code. Once those foundations were in place, we rolled the approach out to a batch of 20 services, completed by the AI in about two working days. That run confirmed our core hypothesis: the agent could scaffold medium tests first, then rely on those tests to show that our Dockerfile change (using distroless image as a base image) had not introduced regressions.&lt;/p&gt;

&lt;h3 id=&quot;teaching-an-agent-to-test&quot;&gt;Teaching an agent to test&lt;/h3&gt;

&lt;p&gt;By then, the real shift was turning “can do the task” into “can repeat the behavior.” We captured the medium-test knowledge as a &lt;strong&gt;list of skills&lt;/strong&gt;, which were grounded in Grab’s internal medium test SDK.&lt;/p&gt;

&lt;p&gt;Next, DevSecOps wrapped those skills into an &lt;strong&gt;Entrypoint Skill&lt;/strong&gt;, an orchestrator that runs a &lt;strong&gt;multi-phase&lt;/strong&gt; workflow across services. The result is a single agent loop that moves from candidate detection to scaffolding, fixing failures, and CI verification, without treating each service as a brand-new, one-off problem.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-5.png&quot; alt=&quot;Workflow Overview for Medium Test Generation.&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 5: Workflow overview for medium test generation&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Leveraging the skills we have acquired, we utilized &lt;a href=&quot;https://docs.anthropic.com/en/docs/claude-code/overview&quot;&gt;Claude Code&lt;/a&gt;, Anthropic’s agentic coding tool. This tool takes a list of services and processes them in a batch.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Detect&lt;/strong&gt;: Is this a deployable service or a library? Is it still maintained? The agent skips anything that does not qualify, so human time is only spent on real candidates.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Scaffold&lt;/strong&gt;: Using Grab’s scaffolding tool, the agent generates the medium test boilerplate.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fix&lt;/strong&gt;: The scaffold rarely works on the first try due to the unique setup of each repository. This includes missing environment variables, database dependencies at startup, and port mismatches. The agent reviews its knowledge base to pattern-match errors against known fixes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Raise MR&lt;/strong&gt;: Once the medium test passes locally, the agent creates a draft merge request on GitLab with a description explaining the service-specific changes and the rationale behind them.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Monitor CI&lt;/strong&gt;: The agent polls the pipeline, reads job logs on failure, and attempts CI-specific fixes. If the same error persists after two attempts, it flags the issue for human review.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Repeat&lt;/strong&gt;: It pushes the fix and moves to the next service while the pipeline runs. The agent does not sit idle waiting for CI. It starts scaffolding the next service in parallel and checks earlier pipelines as results arrive.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;what-made-it-work&quot;&gt;What made it work&lt;/h3&gt;

&lt;p&gt;Getting the workflow to &lt;em&gt;function&lt;/em&gt; was the easy part. Getting it to function &lt;em&gt;reliably across hundreds of services&lt;/em&gt; required deliberate design choices.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;MCP&lt;/strong&gt;: The agent never leaves Claude Code. GitLab interactions such as creating branches, raising MRs and reading pipeline error logs happen through a &lt;a href=&quot;https://modelcontextprotocol.io/&quot;&gt;Model Context Protocol&lt;/a&gt; (MCP) server. When it needs Grab-specific context (what a service does, who owns it), it queries Glean, Grab’s enterprise search tool, via MCP instead of guessing. For code-level context (how a service is structured and how dependencies are wired across repos), it queries Sourcegraph through its own MCP integration.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Guardrails over autonomy&lt;/strong&gt;: The agent can only touch test files and CI configurations; application code is off-limits, enforced before every commit. It might not hollow out tests to force a pass. If it cannot fix an issue, it escalates.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Knowledge that compounds&lt;/strong&gt;: We maintain a feedback loop for scaffolding, mocking, and known failure patterns. After each batch, we review what blocked the agent and fold recurring fixes back into the skill. The agent’s improvement is driven primarily by the iterative refinement of its instructions, rather than changes to the underlying model.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Integrating Scripts with skills&lt;/strong&gt;: For deterministic tasks like boilerplate generation, scripts are far more reliable than raw AI logic. By integrating these scripts as “skills,” we also optimize the agent’s performance in context window management. During test execution, standard output often produces hundreds of lines of repetitive logs that could exhaust token limits or distract the model. Using a script as an intermediary allows us to programmatically filter logs, extracting only the specific error messages or stack traces required for debugging. This ensures the AI receives a clean, actionable summary rather than being overwhelmed by noisy data.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Token efficiency&lt;/strong&gt;: Batches across many services burn tokens quickly. We use a compressed communication style that cuts model output by about 75% while keeping technical detail and dropping filler. Proper communication is reserved for MR descriptions and messages to service owners.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Isolated execution&lt;/strong&gt;: Each service gets its own context window (sub-agents). Long sessions processing dozens of services do not bloat the main conversation, keeping the agent focused and responsive.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Human-in-the-loop&lt;/strong&gt;: Every MR is a draft: a human reviews before anything merges. Humans also decide which learnings become permanent knowledge. The agent proposes; people approve.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;from-tests-to-migration-at-scale&quot;&gt;From tests to migration at scale&lt;/h2&gt;

&lt;p&gt;With medium tests in place across our service fleet, we had the safety net we needed. The next step was automating the Distroless migration itself.&lt;/p&gt;

&lt;h2 id=&quot;the-patch-test-compare-loop&quot;&gt;The Patch-Test-Compare loop&lt;/h2&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/scaling-out-distroless-adoption-with-ai/mammoth-image-6.png&quot; alt=&quot;The Patch-Test-Compare Loop.&quot; style=&quot;width:70%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 6: The patch-test-compare loop&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Before any Dockerfile changes, the system runs the service’s existing medium tests to establish a baseline. Pre-existing test failures are baselined, allowing for a clear distinction between legacy issues and new regressions introduced by the Distroless patch.&lt;/p&gt;

&lt;p&gt;Next comes Distroless patching. The system inspects each service’s Dockerfile for OS-level package dependencies; scanning &lt;strong&gt;apt-get install&lt;/strong&gt; lines and filtering out packages already included in the distroless base image. Two scenarios to consider here:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If no extra packages are needed, it is a straightforward base image swap.&lt;/li&gt;
  &lt;li&gt;If packages are detected, the system generates a multi-stage build: a builder stage installs the required packages, then copies only the necessary shared libraries into the Distroless runtime stage. The result is a minimal image that still contains everything the service needs to run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After patching, the same medium tests run again. Results fall into clear categories: &lt;strong&gt;pass&lt;/strong&gt; (tests still green - safe to migrate), &lt;strong&gt;regression&lt;/strong&gt; (tests fail - the patch introduced a problem), or &lt;strong&gt;already failing&lt;/strong&gt; (the suite was failing before we changed anything). Regressions trigger an automated remediation step: a separate AI agent inspects the container for missing shared libraries and attempts to fix the Dockerfile. If it cannot resolve the issue, the service is flagged for human review.&lt;/p&gt;

&lt;h3 id=&quot;scaling-with-batch-changes&quot;&gt;Scaling with batch changes&lt;/h3&gt;

&lt;p&gt;The previous section explains the patch-test-compare loop; this section addresses how we apply patching changes to more than one service simultaneously. To migrate at scale, we use batch change tooling that applies the Dockerfile transformation across dozens of repositories simultaneously, creating merge requests automatically. The system handles both standalone GitLab repositories and Grab’s shared Go monorepo, adapting the patching and MR strategy to each.&lt;/p&gt;

&lt;h2 id=&quot;impact-on-our-services&quot;&gt;Impact on our services&lt;/h2&gt;

&lt;h3 id=&quot;medium-test-generation-at-zcale&quot;&gt;Medium test generation at zcale&lt;/h3&gt;

&lt;p&gt;With medium tests in place, regressions are more likely to be caught before code reaches staging, which provides the safety guarantee we needed. Each generated test also becomes a lasting safety net for the service, not just for the Distroless migration but for future changes as well. Over roughly 1.5 months, the agent raised more than 100 medium test MRs across repositories, bringing more services into compliance with Grab’s “shift-left” testing initiative.&lt;/p&gt;

&lt;h3 id=&quot;distroless-adoption&quot;&gt;Distroless adoption&lt;/h3&gt;

&lt;p&gt;The campaign moved the needle across our service fleet: overall distroless adoption within our scope has grown substantially since we began using AI to drive the work.&lt;/p&gt;

&lt;h3 id=&quot;autonomous-with-oversight&quot;&gt;Autonomous with oversight&lt;/h3&gt;

&lt;p&gt;For typical cases, the agent autonomously handles the majority of medium test generation and Dockerfile migration work with little human intervention. Engineers remain in the loop, reviewing every draft MR and making the final call on what merges.&lt;/p&gt;

&lt;h3 id=&quot;engineering-bandwidth-reclaimed&quot;&gt;Engineering bandwidth reclaimed&lt;/h3&gt;

&lt;p&gt;Manually generating a basic medium test requires familiarity with Grab’s internal SDK, typically taking one to three days per repository for developers new to the framework. For approximately 400 services lacking these tests, this equates to 400-1,200 engineer-days. By leveraging AI, we reduced the effort to roughly 0.1 days per service, compressing over a year’s worth of work into a fraction of the time. This allowed the team to focus on higher-leverage tasks, such as improving migration tooling, handling edge cases, and advancing the roadmap beyond Distroless.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The integration of Distroless images and enhanced medium test coverage significantly strengthens the security and verifiability of Grab’s services. This initiative demonstrates AI’s capacity to handle the heavy lifting required for large-scale migrations.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebdistroless&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/scaling-out-distroless-adoption-with-ai</link>
        <guid isPermaLink="true">https://engineering.grab.com/scaling-out-distroless-adoption-with-ai</guid>
        
        <category>Security</category>
        
        <category>Containers</category>
        
        <category>Artificial Intelligence</category>
        
        <category>DevSecOps</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Palana (Part 2): Architecting isolation, identity, and auditability for AI agents</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;In &lt;a href=&quot;https://engineering.grab.com/part-1-palana-for-autonomous-ai-agents&quot;&gt;Part 1&lt;/a&gt;, we introduced &lt;strong&gt;Palana&lt;/strong&gt;, Grab’s Kubernetes-native secure execution platform for autonomous AI agents. We discussed the underlying need for isolated environments and covered its core design principles: treating isolation as the unit of trust, keeping credentials out of agent hands, and mediating all network access. In this second part, we’ll dive under the hood into Palana’s architecture, look at the agent lifecycle, and share the key lessons we learned from putting this system into production.&lt;/p&gt;

&lt;h2 id=&quot;architecture-overview&quot;&gt;Architecture overview&lt;/h2&gt;

&lt;p&gt;The core request path looks like this:&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/palana-part-2/palana-architecture.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Palana architecture overview.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The agent pod runs in a namespace owned by one user and one agent. It gets default-deny style network policy, domain name system (DNS), access to required platform services, and a persistent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt; volume. Browser traffic enters through Traefik. LLM traffic goes to the LiteLLM wrapper in the gateway namespace. General Hypertext Transfer Protocol (HTTP) and Hypertext Transfer Protocol Secure (HTTPS) egress goes through the proxy namespace. Secrets are read from Vault only by the component authorized to use them.&lt;/p&gt;

&lt;p&gt;The operator is responsible for turning a user request into the concrete Kubernetes shape:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;The user creates an agent through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pcli&lt;/code&gt; (Palana command-line interface) or the portal.&lt;/li&gt;
  &lt;li&gt;Palana writes a UserAgent or Agent custom resource with the raw user identity.&lt;/li&gt;
  &lt;li&gt;The operator creates the user and agent namespaces, service accounts, role bindings, storage, network policies, and ingress.&lt;/li&gt;
  &lt;li&gt;The user runs a template or container image.&lt;/li&gt;
  &lt;li&gt;Admission webhooks inject proxy environment variables and enforce pod-level restrictions.&lt;/li&gt;
  &lt;li&gt;Logs, policy decisions, and activity signals are emitted to observability systems.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;agent-lifecycle&quot;&gt;Agent lifecycle&lt;/h2&gt;

&lt;p&gt;From a user’s perspective, the basic workflow is intentionally small:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;./pcli login
./pcli create demo
./pcli secrets add demo GRABGPT_API_KEY token=&amp;lt;token&amp;gt;
./pcli run demo --template claudecodeui
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Behind those commands, Palana provisions an isolated execution environment:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Namespace: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-{sanitized-user}-{agent}&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Service account: bound only to that namespace&lt;/li&gt;
  &lt;li&gt;Storage: an Amazon Elastic File System (EFS)-backed persistent volume claim (PVC) mounted at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Ingress: an agent-specific hostname protected by Concedo-backed browser auth&lt;/li&gt;
  &lt;li&gt;Egress: forced through platform proxies, except for approved internal platform services&lt;/li&gt;
  &lt;li&gt;Secrets: split between agent-readable and proxy-only Vault paths&lt;/li&gt;
  &lt;li&gt;Policies: proxy egress, network egress, and optional inter-agent peering rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same lifecycle is exposed in the portal for users who prefer a browser user interface (UI).&lt;/p&gt;

&lt;h2 id=&quot;how-palana-handles-identity&quot;&gt;How Palana handles identity&lt;/h2&gt;

&lt;p&gt;Human authentication uses Concedo OpenID Connect (OIDC). &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pcli login&lt;/code&gt; performs a browser-based authorization code flow with Proof Key for Code Exchange (PKCE) and stores the resulting identity in an isolated kubeconfig. Browser access to agent UIs is protected by OAuth2-Proxy through Traefik forward auth.&lt;/p&gt;

&lt;p&gt;The important detail is that Palana keeps the raw user identity, such as an email address, as the authoritative owner on the custom resource. That raw identity is used for Kubernetes role-based access control (RBAC) subject matching. Sanitized forms are used only where Kubernetes object names, labels, namespaces, or Vault paths require safer strings.&lt;/p&gt;

&lt;p&gt;This split prevents a common class of identity bugs: the display-safe or path-safe version of a user ID should not accidentally become the authorization subject.&lt;/p&gt;

&lt;p&gt;In the future, we will integrate Palana via SPIFFE (Secure Production Identity Framework for Everyone) and SPIRE (SPIFFE Runtime Environment) with the rest of our service mesh, to provide an agentic identity — a combination of user and agent instance id — that can then be controlled as a subset of a user’s capabilities. This gives us a first step into “agents on behalf of users” with cut-down permissions while the wider industry firms up the approaches via Open Authorization (OAuth) and other controls.&lt;/p&gt;

&lt;h2 id=&quot;how-palana-handles-secrets&quot;&gt;How Palana handles secrets&lt;/h2&gt;

&lt;p&gt;Palana’s Vault layout is designed around least privilege:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;kv/agents/{user}/{agent}/{secret}
kv/proxy-secrets/{user}/{agent}/{secret}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The first path is for secrets the agent is allowed to read through its per-agent Vault role. The second path is for credentials the agent can use only through the proxy. For each proxy-only secret, Palana can create an agent-visible placeholder value. The placeholder is inert unless the request goes through the approved proxy path.&lt;/p&gt;

&lt;p&gt;This gives teams a practical migration path. Existing clients can often be configured with a token-looking value, while Palana keeps the real token out of the runtime.&lt;/p&gt;

&lt;h2 id=&quot;how-palana-handles-llm-access&quot;&gt;How Palana handles LLM access&lt;/h2&gt;

&lt;p&gt;LLM calls go through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;litellm-proxy-wrapper&lt;/code&gt;, which sits in front of LiteLLM and GrabGPT. The wrapper derives agent identity from Kubernetes context rather than trusting client-provided headers. It then looks up the per-agent GrabGPT credential in Vault and forwards the request to the correct upstream route.&lt;/p&gt;

&lt;p&gt;The agent config uses internal base URLs such as:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;http://litellm-proxy.gateway:4000/aws/v1
http://litellm-proxy.gateway:4000/unified/v1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That design gives us three useful properties:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Agents do not need raw upstream LLM credentials.&lt;/li&gt;
  &lt;li&gt;LLM traffic is attributable to a specific agent.&lt;/li&gt;
  &lt;li&gt;Provider routing and credential handling can evolve centrally.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;how-palana-handles-network-access&quot;&gt;How Palana handles network access&lt;/h2&gt;

&lt;p&gt;Network control is split into two layers.&lt;/p&gt;

&lt;p&gt;At Layer 3 and Layer 4, Kubernetes NetworkPolicy and Cilium enforce which pods can talk to which namespaces, services, and classless inter-domain routing (CIDR) blocks. Agent namespaces are locked down to the platform paths they need: DNS, Vault, the egress proxy, the LLM gateway, and the Kubernetes application programming interface (API) patterns the platform explicitly supports.&lt;/p&gt;

&lt;p&gt;At Layer 7, the proxy policy controls HTTP and HTTPS destinations by host, method, and agent identity. Open Policy Agent (OPA) evaluates per-agent policy. The proxy logs allow and deny decisions in structured form.&lt;/p&gt;

&lt;p&gt;This split is deliberate. NetworkPolicy is good at containment. The proxy is good at application-aware decisions and audit. This allows us to be very expressive in the restrictions we place on our agents — by default, they get nothing; if they should have access to an internal service they get only that service, and cannot be used as an entry point to the wider internal environment.&lt;/p&gt;

&lt;h2 id=&quot;observability-and-operations&quot;&gt;Observability and operations&lt;/h2&gt;

&lt;p&gt;Palana treats observability as part of the safety model, not a nice-to-have. The platform emits structured logs for proxy decisions, Git activity, LLM requests, agent lifecycle, and idle-shutdown decisions. Operators can query activity by namespace, user, host, decision, or component.&lt;/p&gt;

&lt;p&gt;One example is idle shutdown. Long-running agents are useful, but idle workloads consume cluster resources and expand the surface area that platform teams must monitor. Palana’s reaper records the most recent observable activity for each UserAgent. It combines signals from gateway/proxy logs, Git activity, Slack-routed agent messages, and Prometheus network activity. After a configurable idle threshold, it can warn the user and stop the workload while preserving &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt;, RBAC, namespace, and Vault state.&lt;/p&gt;

&lt;p&gt;This is a good example of the platform philosophy: stop the compute, keep the state, and make resumption easy.&lt;/p&gt;

&lt;p&gt;In addition, as we move into agentic operations, we use the many signals generated by Palana itself to aid our agents. For example, we have an agent that can monitor user workloads and provide advice and assistance if it spots issues — say, an agent is consistently out of memory (OOM), the ops agent can see that and message the user with instructions on how to increase the allocated memory. We don’t need to special-case every possible issue; instead we have agents that understand Palana logs and are able to communicate with the users themselves.&lt;/p&gt;

&lt;h2 id=&quot;what-we-learned&quot;&gt;What we learned&lt;/h2&gt;

&lt;h3 id=&quot;agent-platforms-need-security-controls-at-the-platform-layer&quot;&gt;Agent platforms need security controls at the platform layer&lt;/h3&gt;

&lt;p&gt;Prompt-level guardrails and model policies are useful, but they are not enough. Agents call tools, tools call services, and services use credentials. Palana puts controls where the action crosses a trust boundary: identity, egress, secrets, ingress, Git, and Kubernetes API access.&lt;/p&gt;

&lt;h3 id=&quot;the-user-experience-matters-as-much-as-the-control&quot;&gt;The user experience matters as much as the control&lt;/h3&gt;

&lt;p&gt;If the secure path requires every team to learn Terraform, Vault policy syntax, Kubernetes RBAC, and proxy configuration before they can try an agent, teams will work around it. Palana uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pcli&lt;/code&gt;, templates, and the portal to make the safe path the easy path.&lt;/p&gt;

&lt;h3 id=&quot;separating-can-read-a-credential-from-can-cause-a-credentialed-request-is-powerful&quot;&gt;Separating “can read a credential” from “can cause a credentialed request” is powerful&lt;/h3&gt;

&lt;p&gt;Proxy-only secrets are one of the highest-leverage design choices. They let agents perform authenticated work without turning the agent filesystem, logs, process environment, or prompt context into a credential store.&lt;/p&gt;

&lt;h3 id=&quot;a-namespace-boundary-is-simple-but-it-compounds&quot;&gt;A namespace boundary is simple, but it compounds&lt;/h3&gt;

&lt;p&gt;Per-agent namespaces give us a consistent place to apply RBAC, storage, network policy, logging labels, resource quotas, and lifecycle controls. The pattern is easy to reason about during incidents: identify the namespace, identify the owner, inspect the policy, and isolate if needed.&lt;/p&gt;

&lt;h3 id=&quot;long-running-agents-need-lifecycle-management&quot;&gt;Long-running agents need lifecycle management&lt;/h3&gt;

&lt;p&gt;Once agents persist for days or weeks, “run a container” becomes an incomplete product. Users need resume semantics. Operators need idle cleanup. Security teams need audit history. Platform teams need a way to rotate credentials, update images, and stop workloads externally.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;

&lt;p&gt;Palana is increasingly becoming a substrate for larger autonomous systems rather than only a place to run individual agents. Emerging patterns include:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Supervisor systems that route work to a pool of scoped agents.&lt;/li&gt;
  &lt;li&gt;Slack-native agents that wake up, handle a task, and scale back down.&lt;/li&gt;
  &lt;li&gt;Remote development environments backed by persistent cloud state.&lt;/li&gt;
  &lt;li&gt;Agent swarms where each worker has a separate namespace and credential scope.&lt;/li&gt;
  &lt;li&gt;Operational agents that investigate platform health and propose or apply small fixes under policy.&lt;/li&gt;
  &lt;li&gt;Security experiments around supply chain monitoring, token rotation, transport layer security (TLS) inspection, and automated isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The north star is not “let every agent do anything”&lt;/strong&gt;. It is to make useful autonomy boring to operate: attributable, inspectable, revocable, and recoverable.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;AI agents are most valuable when they can act in real environments. That is also when they become risky. Palana gives Grab a way to keep both sides of that tradeoff in view: teams can move quickly with self-service agent environments, while the platform keeps isolation, identity, secrets, network access, and auditability as defaults.&lt;/p&gt;

&lt;p&gt;We expect the underlying tools and models to keep changing. The platform primitives are more durable. Agents will vary, but they will still need a place to run, a way to authenticate, a boundary around their actions, and a record of what happened.&lt;/p&gt;

&lt;p&gt;That is the role Palana is designed to play.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebpalana2&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/part-2-palana-architecture</link>
        <guid isPermaLink="true">https://engineering.grab.com/part-2-palana-architecture</guid>
        
        <category>Security</category>
        
        <category>Artificial Intelligence</category>
        
        <category>Kubernetes</category>
        
        <category>DevSecOps</category>
        
        <category>Platform</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>Palana (Part 1): Why Grab built a secure platform for autonomous AI Agents</title>
        <description>&lt;h2 id=&quot;abstract&quot;&gt;Abstract&lt;/h2&gt;

&lt;p&gt;Artificial intelligence (AI) agents are moving from experiments into everyday engineering workflows. They can read code, call application programming interfaces (APIs), run tests, create merge requests, answer Slack messages, and keep long-running state. That makes them useful, but it also changes the risk model - especially as agents get more autonomous in their use of tools. An agent with network access, credentials, tools, and memory is no longer just a chat interface. It is a workload that can act.&lt;/p&gt;

&lt;p&gt;The more capability we give to the agents, the more valuable they get - but they also get riskier, and maintaining controls and oversight gets more challenging. We need isolated environments, with clear intentional capabilities added rather than just inheriting “everything on your laptop”.&lt;/p&gt;

&lt;p&gt;Palana is Grab’s Kubernetes-native platform for running those workloads safely. It gives each agent an isolated namespace, persistent storage, controlled ingress, proxy-mediated egress, Vault-backed credential injection, large language model (LLM) routing, Git access controls, structured audit logs, and emergency kill switches. It is currently used to run hundreds of agents, including remote development environments, Slack automation, OpenClaw workers, Hermes agents, and other long-running internal systems.&lt;/p&gt;

&lt;p&gt;In this post, we share why we built Palana, what it does, and how its architecture lets teams experiment with autonomous agents without giving up control over identity, secrets, network access, and operational visibility.&lt;/p&gt;

&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;The first wave of AI coding tools lived close to the user: an integrated development environment (IDE) plugin, a chat window, or a command-line assistant running on a developer’s laptop. That model is familiar and easy to adopt, but it has limits. Long-running agents need persistent state. Team workflows need shared access through Slack or web user interfaces (UIs). Security teams need to inspect what an agent is doing, and apply highly granular controls over what an agent can do. Platform teams need a way to stop, resume, update, and audit the workload.&lt;/p&gt;

&lt;p&gt;As usage grew, we started seeing the same question in different forms:&lt;/p&gt;

&lt;p&gt;How do we let agents do useful work inside the company without treating every new agent as a bespoke infrastructure project?&lt;/p&gt;

&lt;p&gt;The answer was not simply to “run agents in containers”. Containers help package the runtime, but they do not answer the harder platform questions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Which user does this agent act on behalf of?&lt;/li&gt;
  &lt;li&gt;What credentials can it use?&lt;/li&gt;
  &lt;li&gt;Can it see another user’s state?&lt;/li&gt;
  &lt;li&gt;Can it connect directly to the internet?&lt;/li&gt;
  &lt;li&gt;How do we inspect LLM, Git, and Hypertext Transfer Protocol (HTTP) activity after something goes wrong?&lt;/li&gt;
  &lt;li&gt;How do we stop an agent quickly without trusting the agent to cooperate?&lt;/li&gt;
  &lt;li&gt;How do we give teams a self-service experience without handing them cluster-admin access?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Palana is our answer to those questions.&lt;/p&gt;

&lt;h2 id=&quot;what-palana-is&quot;&gt;What Palana is&lt;/h2&gt;

&lt;p&gt;Palana, an in-house proprietary system built by the CyberSecurity team at Grab, is a secure execution substrate for autonomous and semi-autonomous agents. The name comes from a Sanskrit root associated with protection, maintenance, and care. That maps well to the platform’s purpose: Palana is not trying to be the agent’s brain. It is the environment that contains, observes, and sustains the agent while it works.&lt;/p&gt;

&lt;p&gt;At a high level, Palana provides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A Kubernetes namespace per agent, with role-based access control (RBAC), resource quotas, network policy, and storage scoped to that agent.&lt;/li&gt;
  &lt;li&gt;A command-line and portal experience for creating, running, stopping, configuring, and inspecting agents.&lt;/li&gt;
  &lt;li&gt;Persistent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data&lt;/code&gt; storage so long-running agents can preserve memory, caches, repositories, and session state across restarts.&lt;/li&gt;
  &lt;li&gt;Browser and shell access for interactive workloads such as Claude Code UI, OpenCode, IDEs, ttyd, or Secure Shell (SSH)-backed development flows.&lt;/li&gt;
  &lt;li&gt;LLM access through a LiteLLM wrapper that injects per-agent GrabGPT credentials from Vault.&lt;/li&gt;
  &lt;li&gt;HTTP and HTTPS egress through an Envoy and ext-authz proxy path, with Open Policy Agent (OPA) policy checks and structured request logs.&lt;/li&gt;
  &lt;li&gt;Proxy-only secrets, where agents can reference placeholder tokens but cannot read the underlying credentials directly.&lt;/li&gt;
  &lt;li&gt;Git access through a bastion path so repository operations are attributable and policy-controlled.&lt;/li&gt;
  &lt;li&gt;Kill switches and idle shutdown so the control plane can isolate or stop workloads from outside the agent process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This combination lets Palana support several categories of work:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Secure OpenClaw and agent-framework testing.&lt;/li&gt;
  &lt;li&gt;Cloud development environments accessible from a browser or SSH client.&lt;/li&gt;
  &lt;li&gt;Fast prototyping and testing for agentic workloads in a secure environment.&lt;/li&gt;
  &lt;li&gt;Slack-connected agents such as cts-aergia and Claude-to-Slack workflows.&lt;/li&gt;
  &lt;li&gt;Long-running task agents such as Hermes, Matlock, Butler, and custom team automations.&lt;/li&gt;
  &lt;li&gt;Higher-order systems where agentic supervisors launch or route work to scoped agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;why-we-built-it&quot;&gt;Why we built it&lt;/h2&gt;

&lt;p&gt;The immediate need came from security research. We wanted a place to run and investigate OpenClaw and related agent frameworks without exposing the broader internal network or placing raw credentials inside the agent runtime. That use case forced us to design for containment from the beginning.&lt;/p&gt;

&lt;p&gt;The broader need quickly became developer productivity. Once the basic primitives existed, Palana became useful for remote coding, Slack automation, internal assistants, long-lived experiments, and agentic operational workflows. Grabbers wanted agents that could keep context over days or weeks, run from corporate infrastructure, access approved internal services, and survive laptop sleep, local dependency drift, or network changes.&lt;/p&gt;

&lt;p&gt;The security and productivity goals reinforce each other. &lt;strong&gt;If the safe path is self-service and ergonomic, teams are more likely to use it&lt;/strong&gt;. If the productive path is observable and policy-controlled by default, and the appropriate security is baked into the system automatically, platform teams do not have to retrofit controls after adoption.&lt;/p&gt;

&lt;h2 id=&quot;design-principles&quot;&gt;Design principles&lt;/h2&gt;

&lt;p&gt;Palana’s architecture follows a few principles that shaped most of the implementation.&lt;/p&gt;

&lt;h3 id=&quot;isolation-is-the-unit-of-trust&quot;&gt;Isolation is the unit of trust&lt;/h3&gt;

&lt;p&gt;Each agent gets its own namespace, service account, storage, network policy, and Vault scope. Agents should not see each other’s pods, secrets, or filesystem state by default. Inter-agent communication is possible, but it goes through explicit peering rules rather than ambient pod-to-pod reachability.&lt;/p&gt;

&lt;p&gt;This means the platform does not have to assume every agent framework has perfect multi-tenant isolation internally. A framework designed as a single-user assistant can still be hosted safely by giving each user or worker its own Palana boundary.&lt;/p&gt;

&lt;h3 id=&quot;credentials-are-never-given-to-the-agent&quot;&gt;Credentials are never given to the agent&lt;/h3&gt;

&lt;p&gt;Traditional application hosting often gives credentials to the workload as environment variables or mounted files. That is risky for agent workloads because the agent may execute tools, run untrusted code, summarize files, install packages, or expose a web UI.&lt;/p&gt;

&lt;p&gt;Palana separates two kinds of secrets:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Agent-readable secrets live under the agent’s own Vault path and are available only to that agent’s service account.&lt;/li&gt;
  &lt;li&gt;Proxy-only secrets are stored under a separate Vault path and are read by the proxy layer, not by the agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For proxy-only secrets, the agent sees a placeholder such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKEN_GITHUB_PAT&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKEN_GRABGPT_API_KEY&lt;/code&gt;. When an outbound request travels through the proxy path, the proxy replaces the placeholder header with the real credential from Vault. The remote service receives a valid token, but the agent process never stores the token in its own environment or config.&lt;/p&gt;

&lt;p&gt;This pattern is especially important for LLMs, source control, API integrations, and browser-like tools where prompt injection or dependency compromise could otherwise expose long-lived credentials.&lt;/p&gt;

&lt;h3 id=&quot;egress-is-a-control-point&quot;&gt;Egress is a control point&lt;/h3&gt;

&lt;p&gt;Agents can be useful only if they can call tools and services. Instead of forbidding network access, Palana makes network access observable and policy-mediated.&lt;/p&gt;

&lt;p&gt;Agent pods receive proxy configuration automatically. External HTTP and HTTPS traffic flows through Envoy. Envoy asks ext-authz-proxy to identify the calling pod, evaluate policy with OPA, log the request, and optionally inject credentials. HTTPS traffic can be terminated by the proxy’s man-in-the-middle (MITM) listener for header inspection and replacement, with the generated certificate authority (CA) distributed to agent pods.&lt;/p&gt;

&lt;p&gt;This gives the platform a place to answer questions that normal Kubernetes networking cannot answer alone:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Which agent made this request?&lt;/li&gt;
  &lt;li&gt;Which user owns that agent?&lt;/li&gt;
  &lt;li&gt;Which host and method were requested?&lt;/li&gt;
  &lt;li&gt;Was the request allowed or denied?&lt;/li&gt;
  &lt;li&gt;Which placeholder credentials were replaced?&lt;/li&gt;
  &lt;li&gt;Did the request go to an internal service, an LLM gateway, GitLab, or the public internet?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;the-control-plane-must-stay-outside-the-agent&quot;&gt;The control plane must stay outside the agent&lt;/h3&gt;

&lt;p&gt;Palana assumes an agent might become confused, compromised, or uncooperative. Operational controls therefore live outside the agent process. The operator reconciles namespaces and policies. The proxy controls egress. The portal and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pcli&lt;/code&gt; (Palana command-line interface) manage lifecycle. The kill switch is enforced with network policy. Idle shutdown is handled by a separate reaper CronJob.&lt;/p&gt;

&lt;p&gt;That separation matters. A kill switch that asks the agent to stop is a feature. A kill switch that removes the agent’s network path is a safety control.&lt;/p&gt;

&lt;h3 id=&quot;use-kubernetes-primitives-where-they-fit&quot;&gt;Use Kubernetes primitives where they fit&lt;/h3&gt;

&lt;p&gt;Palana is intentionally Kubernetes-native. Agents are represented by custom resources. The operator reconciles namespaces, RBAC, storage, services, ingress, and network policies. Users can interact through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pcli&lt;/code&gt; or the portal, while platform engineers can still inspect the underlying Kubernetes objects when debugging.&lt;/p&gt;

&lt;p&gt;This gives us a layered experience: simple workflows for users, direct primitives for advanced operators, and infrastructure-as-code for the deployed platform.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;By centering the design around isolation, controlled egress, and proxy-mediated secrets, Palana provides a secure foundation for AI agents to operate within Grab. In Part 2, we will dive deeper into the under-the-hood architecture of Palana, exploring how it orchestrates agent lifecycles, handles LLM routing, and maintains operational visibility.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebpalana1&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/palana-part-1-secure-platform-for-ai-agents</link>
        <guid isPermaLink="true">https://engineering.grab.com/palana-part-1-secure-platform-for-ai-agents</guid>
        
        <category>Security</category>
        
        <category>Artificial Intelligence</category>
        
        <category>Kubernetes</category>
        
        <category>DevSecOps</category>
        
        <category>Platform</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>From decentralized Docs-as-Code to a centralized repository: Evolving Grab&apos;s documentation strategy</title>
        <description>&lt;h2 id=&quot;introduction-the-journey-of-documentation-at-grab&quot;&gt;Introduction: The journey of documentation at Grab&lt;/h2&gt;

&lt;p&gt;In early 2021, Grab adopted a Docs-as-Code approach to address gaps in our technical documentation processes, as illustrated in our blog post &lt;a href=&quot;https://engineering.grab.com/doc-as-code&quot;&gt;Embracing a Docs-as-Code&lt;/a&gt;. Inspired by the practices of other market leaders, we integrated documentation into our engineers’ workflows, making it part of the codebase.&lt;/p&gt;

&lt;p&gt;This approach addressed our initial documentation challenges by creating a single source of truth for engineers to search and build knowledge, making documentation upkeep necessary and less of an afterthought. After four years of use, we transitioned to a centralized documentation repository. This change was not about abandoning Docs-as-Code but adapting it to meet new and growing organizational needs.&lt;/p&gt;

&lt;p&gt;This post walks through the motivations, benefits, and lessons from each phase of this journey, showing how our documentation strategy evolved.&lt;/p&gt;

&lt;h2 id=&quot;what-is-docs-as-code&quot;&gt;What is Docs-as-Code?&lt;/h2&gt;

&lt;p&gt;Docs-as-Code is an approach that manages documentation with the same tools and workflows engineers use for source code. Content is written in plain-text Markdown, which is easy to edit in any code editor. Markdown is a lightweight markup language that uses simple, readable symbols like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#&lt;/code&gt; for headings and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;* &lt;/code&gt; for lists to format text, and can be rendered to HTML and other outputs. It lives in version-controlled repositories (e.g., GitLab), so documentation evolves alongside code. Updates go through the same merge request reviews and automated CI/CD checks.&lt;/p&gt;

&lt;p&gt;This integrated model lets teams at Grab build, test, and publish documentation as part of a pipeline. We then surface it through a centralized internal developer portal for easier discovery and implement governance for quality assurance.&lt;/p&gt;

&lt;h3 id=&quot;ideal-use-case-at-grab&quot;&gt;Ideal use case at Grab&lt;/h3&gt;

&lt;p&gt;Imagine an engineer responsible for maintaining documentation for a product or platform managed by their team. This engineer creates comprehensive documentation in Markdown, containing pages such as an overview, a getting-started guide, how-tos, troubleshooting, FAQs, and related references for a specific platform. The documentation is included in the merge request and published on the documentation portal immediately after the code is merged. This seamless integration fosters a sense of ownership over the documentation. However, while this scenario is ideal, implementing it in practice presents significant challenges.&lt;/p&gt;

&lt;h3 id=&quot;problems-we-solved-with-docs-as-code&quot;&gt;Problems we solved with Docs-as-Code&lt;/h3&gt;

&lt;p&gt;Before adopting a Docs-as-Code model, documentation was often scattered across Google Docs, slide decks, wikis, and ad hoc text files, which led to version confusion, poor discoverability, and gaps in quality assurance. Centralizing documentation in version-controlled repositories next to the code creates a single source of truth, ties updates to the same pull/merge request reviews, and enables automated checks such as link validation, style linting, and preview builds.&lt;/p&gt;

&lt;p&gt;Industry practice reflects this shift: &lt;a href=&quot;https://kubernetes.io/docs/contribute/docs/&quot;&gt;Kubernetes&lt;/a&gt; maintains its documentation as Markdown on the Kubernetes website, uses GitHub as a repository, and builds the site with Hugo, encouraging doc updates alongside feature work.&lt;/p&gt;

&lt;p&gt;When documentation is embedded with the code and flows through the same CI/CD pipeline, engineers are more likely to update it in tandem with code changes. This method keeps the content up to date and in sync with releases by default. The TechDocs team can also set standardized metrics to uphold quality across all documentation and implement quality gates and blockers to ensure each document meets quality standards.&lt;/p&gt;

&lt;h2 id=&quot;the-limits-of-decentralized-repositories&quot;&gt;The limits of decentralized repositories&lt;/h2&gt;

&lt;p&gt;As Grab’s engineering footprint expanded, our decentralized Docs-as-Code approach began to strain at scale, surfacing friction that made documentation harder to discover, maintain, and ship with confidence.&lt;/p&gt;

&lt;h3 id=&quot;fragmented-user-experience-and-uneven-standards&quot;&gt;Fragmented user experience and uneven standards&lt;/h3&gt;

&lt;p&gt;When documentation is scattered across many repositories and managed independently by teams, information architecture, voice, terminology, and granularity diverge. Similar concepts end up with different names, pages follow inconsistent navigation and templates, and redundant or misaligned guidance proliferates.&lt;/p&gt;

&lt;p&gt;Ultimately, the search experience becomes noisy and unreliable as multiple versions of “the truth” surface. The impact shows up as longer onboarding, more tech support escalations, slower incident response when runbooks differ by team, and eroding trust that eventually pushes people toward tribal knowledge.&lt;/p&gt;

&lt;p&gt;For the TechDocs team, decentralization made it hard to enforce standard templates, formatting, and quality gates. With documentation spread across many repositories, each with different or no linters, CI setups, and conventions, running organization-wide automation (linters, link checkers, readability checks) or applying uniform review steps was unreliable. This resulted in limited oversight and persistent inconsistencies, which degraded the user experience and trust in the documentation.&lt;/p&gt;

&lt;h3 id=&quot;difficulty-keeping-pace-and-staying-discoverable&quot;&gt;Difficulty keeping pace and staying discoverable&lt;/h3&gt;

&lt;p&gt;A fast-moving platform means decentralized documentation ages quickly and becomes hard to find. Frequent infrastructure and framework releases introduce breaking changes and deprecations. Teams struggled to stay informed, leading to missed opportunities for optimization and potential security risks due to outdated practices.&lt;/p&gt;

&lt;p&gt;Meanwhile, with content sprawled across many repositories, managing and tracking the content became increasingly challenging for the team overseeing TechDocs. When teams changed the location of their source repositories, they often failed to notify the managing team, making it difficult to keep track of newer and updated locations. This lack of coordination created significant hurdles in discovering relevant documentation and maintaining a centralized record, ultimately impacting productivity and delaying decision-making.&lt;/p&gt;

&lt;h2 id=&quot;why-we-transitioned-to-a-centralized-repository&quot;&gt;Why we transitioned to a centralized repository&lt;/h2&gt;

&lt;p&gt;A centralized repository allowed us to address these scaling challenges while keeping the benefits of Docs-as-Code:&lt;/p&gt;

&lt;h3 id=&quot;ai-driven-enhancements&quot;&gt;AI-driven enhancements&lt;/h3&gt;

&lt;p&gt;We are no longer writing only for human engineers. As we integrate more AI tools into our developer experience, our documentation also serves as the knowledge base for internal agents. A centralized, Markdown-based format gives agents clean, readable content in one location, which supports better integration, faster comprehension, and more accurate responses.&lt;/p&gt;

&lt;h3 id=&quot;improved-quality-assurance&quot;&gt;Improved quality assurance&lt;/h3&gt;

&lt;p&gt;Centralizing our documentation enabled the managing team to run automated linters for quality checks across all content. This helped ensure consistent standards, reducing manual oversight and minimizing the risk of errors. Contributors were also required to use the appropriate template for each document type, ensuring a consistent structure by default.&lt;/p&gt;

&lt;h3 id=&quot;unified-search-experience&quot;&gt;Unified search experience&lt;/h3&gt;

&lt;p&gt;The unified search experience changes how engineers access information. They can search for any topic and find relevant documentation without navigating multiple repositories. A global search overlay combines two methods: fuzzy page-title search for quick navigation and Glean-powered search across all TechDocs content. Glean is an enterprise search and AI assistant platform that integrates with internal tools to help users find and use information more efficiently. This search capability saves time and helps engineers stay informed.&lt;/p&gt;

&lt;h3 id=&quot;streamlined-contribution-process&quot;&gt;Streamlined contribution process&lt;/h3&gt;

&lt;p&gt;While the decentralized model allowed engineers to use the GitLab web IDE, local editors, and GitLab CLI commands for faster updates, the transition to a centralized system helped streamline this process by offering a consistent editing environment. Even with these advanced tools, the centralized repository provided a unified location for all documentation, reducing the need to navigate across multiple repositories.&lt;/p&gt;

&lt;p&gt;Centralization also gives the TechDocs team clearer visibility into documentation behavior and health. After implementing a centralized repository, the team extracted statistics on user activity: a new update is merged roughly every 50 minutes, with roughly 27 commits per day, and approximately 63% of changes being small to medium improvements. These signals point to ongoing documentation maintenance, with frequent touch-ups that fix typos, clarify steps, and keep guidance current rather than sporadic bulk updates. The image below illustrates how Grabbers use the centralized repository in practice.&lt;/p&gt;

&lt;h2 id=&quot;reflecting-on-the-evolution&quot;&gt;Reflecting on the evolution&lt;/h2&gt;

&lt;p&gt;The transition was not without its hurdles. To bridge the gap left by decentralized Docs-as-Code workflows, we implemented:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Automated syncs:&lt;/strong&gt; We synchronized critical content from service and platform repositories into a central hub to prevent gaps, while keeping the overlap period short to avoid two sources of truth and missed updates as legacy repos were retired.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Training sessions:&lt;/strong&gt; We ran hands-on workshops to help engineers navigate the new platform and understand its benefits.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Continuous feedback:&lt;/strong&gt; We set up surveys and regular check-ins to refine tooling and processes based on real-world usage.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion-choosing-what-works-for-your-context&quot;&gt;Conclusion: choosing what works for your context&lt;/h2&gt;

&lt;p&gt;Docs-as-Code with decentralized and centralized repositories are not mutually exclusive; they excel in different contexts and can be combined. Decentralized authoring works well when engineers are the primary contributors and documentation naturally ships with code. Centralization becomes valuable when you optimize for organization-wide discoverability, consistency, governance, and analytics. We conclude with these findings from our shift to a centralized Docs-as-Code repository:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Use decentralized Docs-as-Code when teams need autonomy and documentation is tightly coupled to services.&lt;/li&gt;
  &lt;li&gt;Use a centralized repository when you need a single source of truth for discovery, standardized templates and style, consistent CI checks, ownership metadata, and clearer compliance and review gates.&lt;/li&gt;
  &lt;li&gt;Consider a hybrid approach: authors create documentation in service repos and publish to a central portal with shared templates, ownership metadata, automated quality checks, and centralized discovery and governance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At Grab, decentralized Docs-as-Code fostered strong ownership early on. As we scaled and our audience broadened, a centralized repository and unified discovery surface became essential to maintain consistency, improve findability, and support diverse user needs. Documentation strategies evolve with the organization. The goal is not picking one model forever, but recognizing the signals to pivot and adapting so engineers can reliably find the right information at the right time.&lt;/p&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebdocstrat&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;
</description>
        <pubDate>Fri, 29 May 2026 00:00:00 +0000</pubDate>
        <link>https://engineering.grab.com/evolving-documentation-strategy</link>
        <guid isPermaLink="true">https://engineering.grab.com/evolving-documentation-strategy</guid>
        
        <category>Blog</category>
        
        <category>TechDocs</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
      <item>
        <title>The Hugo evolution: Engineering Grab&apos;s unified, one-click data ingestion platform with Apache Flink</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;Data drives every decision we make at Grab. As our operations scale, so does our need for robust, real-time data ingestion and processing frameworks. Enter Hugo: our self-service data platform that has long empowered teams to seamlessly route data into our Data Lake. Today, Hugo is evolving. We have taken previously siloed onboarding workflows and transformed them into one seamless, unified journey to truly democratize data ingestion and maximize efficiency.&lt;/p&gt;

&lt;p&gt;In this blog, we’ll share how Hugo turns complex engineering hurdles into a frictionless, self-service reality. By moving away from siloed workflows, we’ve achieved a unified pipeline experience where one-click RDS CDC and self-service Kafka ingestion are the new standard.&lt;/p&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:60%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Hugo - Ingests data from every source into Grab&apos;s data lake.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Hugo was originally designed as a self-service platform for batch-oriented data ingestion into the Data Lake, built on a single computation engine, Spark. It provided a centralized and streamlined onboarding experience for data sources such as MySQL, Aurora, PostgreSQL, and DynamoDB.&lt;/p&gt;

&lt;p&gt;As the organization’s data platform evolved toward near &lt;a href=&quot;https://engineering.grab.com/real-time-data-ingestion&quot;&gt;real-time ingestion&lt;/a&gt;, Hugo expanded to support streaming pipelines from Kafka and MySQL binlog. This evolution introduced a more distributed architecture, where ingestion workflows spanned multiple systems, including Kafka Connect, Sprinkler (an in-house Go-based S3 writer), and Hugo.&lt;/p&gt;

&lt;h2 id=&quot;the-siloed-past-a-multi-platform-hurdle&quot;&gt;The siloed past: A multi-platform hurdle&lt;/h2&gt;

&lt;p&gt;While powerful, the expanded architecture introduced significant onboarding friction. Creating a single data pipeline now requires users to coordinate across multiple platforms, each with its own configuration model and operational semantics. As a result, the onboarding journey became fragmented and difficult to navigate, especially for new users.&lt;/p&gt;

&lt;p&gt;The common challenge during onboarding was helping users understand how configurations mapped across systems.&lt;/p&gt;

&lt;p&gt;For MySQL CDC pipelines, users often asked, &lt;em&gt;“I’ve already configured Kafka Connect, what values do I need to provide in Hugo?”&lt;/em&gt; after setting up a Kafka Connect job. This revealed a gap in abstraction between systems, requiring users to manually translate concepts and configurations across different platforms.&lt;/p&gt;

&lt;p&gt;For Kafka pipelines, users frequently struggled with schema evolution in the data lake. Common questions included: &lt;em&gt;“How should I update the data lake schema?”&lt;/em&gt; and &lt;em&gt;“I’ve already updated the Protobuf schema for this Kafka topic, why isn’t the latest schema reflected in the data lake?”&lt;/em&gt; These issues highlighted unclear expectations around schema propagation and synchronization across the pipeline.&lt;/p&gt;

&lt;p&gt;This multi-step, cross-system dependency increased cognitive load, slowed down onboarding, and created coordination overhead between platform teams and users.&lt;/p&gt;

&lt;h2 id=&quot;the-hugo-evolution-a-unified-ingestion-platform&quot;&gt;The Hugo evolution: A unified ingestion platform&lt;/h2&gt;

&lt;p&gt;Hugo’s new, deeply automated ingestion framework, built with a custom automation layer and Apache Flink, has unified workflows and retired Sprinkler and Kafka Connect. This evolution converted manual, artisanal work into a streamlined, self-service experience, with custom automation serving as the “intelligent chassis” for the entire user journey.&lt;/p&gt;

&lt;h3 id=&quot;the-hugo-ingestion-architecture-engineering-a-unified-flow&quot;&gt;The Hugo ingestion architecture: Engineering a unified flow&lt;/h3&gt;

&lt;h4 id=&quot;one-click-mysql-cdc-pipelines&quot;&gt;One-click MySQL CDC pipelines&lt;/h4&gt;

&lt;p&gt;The transition to a &lt;strong&gt;unified modernized pipeline powered by Flink CDC&lt;/strong&gt; shifts the data ingestion architecture from a fragmented, high-maintenance toolchain into a single, end-to-end orchestrated platform. By reading the database binlog directly and embedding the lifecycle within a centralized control plane, the modernized approach drastically reduces operational overhead, eliminates data mismatch risks, and cuts onboarding times from days to minutes. Below are the core advantages of adopting Flink:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Minimal operational overhead:&lt;/strong&gt; It reduces the footprint from &lt;strong&gt;4 disparate components&lt;/strong&gt; (Kafka Connect, topics, Sprinkler app, and Spark) to just &lt;strong&gt;2 core components&lt;/strong&gt; managed via a single control plane.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Eliminated schema risk:&lt;/strong&gt; It replaces brittle, manually coded Go DTOs, which caused frequent schema deviations, with &lt;strong&gt;automated schema detection&lt;/strong&gt; and dynamic validation.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Streamlined architecture:&lt;/strong&gt; It eliminates the intermediary Kafka hop. Flink reads the MySQL binlog directly and pushes straight to a queryable Hive table via an integrated Spark compaction process.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Instant onboarding:&lt;/strong&gt; It shifts deployment from a multi-team, ticket-heavy process taking &lt;strong&gt;days&lt;/strong&gt; to a single-engineer, self-service setup completed in &lt;strong&gt;minutes&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/figure-2.png&quot; alt=&quot;&quot; style=&quot;width:100%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2. Data ingestion with MySQL CDC to data lake&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h4 id=&quot;self-service-kafka-ingestion&quot;&gt;Self-service Kafka ingestion&lt;/h4&gt;

&lt;p&gt;The most significant architectural shift in the self-service Kafka ingestion pipeline is the move from manual, fragile schema handling to an automated, resilient system. This comparison highlights the operational pain points eliminated by adopting Flink’s approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy Sprinkler approach (manual and static)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Static registration and hardcoding:&lt;/strong&gt; It required manual registration of streams within the Go monorepo and relied on hardcoded mappings in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entities.go&lt;/code&gt; to convert Protobuf to Avro.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Custom dependencies:&lt;/strong&gt; Avro schema was generated indirectly from custom DTO structs, not directly from the Protobuf definition.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Manual schema evolution:&lt;/strong&gt; Any field change required a multi-step manual process: updating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.pb.go&lt;/code&gt; and entity files, followed by a manual pipeline rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;New Flink approach (automated and dynamic)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Dynamic runtime fetching:&lt;/strong&gt; Flink pipelines dynamically retrieve the Protobuf schema from Confluent Schema Registry on startup, removing the need for hardcoding and manual stream registration.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Reduced operational overhead for schema changes:&lt;/strong&gt; Schema updates are propagated through the CI pipeline to the Schema Registry, removing the need for hardcoded mapping changes. The Flink pipeline can detect updated schemas and resume from the latest checkpoint after restart, though manual restart intervention is still required.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Click-to-query:&lt;/strong&gt; Engineers can now ingest streaming data from Kafka topics into queryable Hive tables through a few clicks in the Hugo UI. Hugo automatically orchestrates the multi-stage background work, from Flink consumption and S3 writing to Spark compaction, ensuring data is query-optimized and ready for immediate use.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/figure-3.png&quot; alt=&quot;&quot; style=&quot;width:100%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3. Data ingestion with Kafka to Datalake.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h2 id=&quot;impact&quot;&gt;Impact&lt;/h2&gt;

&lt;p&gt;The platform’s new onboarding workflow has significantly reduced a previously multi-day process to mere minutes, enabling faster iteration and improving overall onboarding efficiency. This dramatic change has fundamentally altered how our teams interact with data.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/table.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/figure-4.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 4. Kafka Flink.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/flink-in-hugo/figure-5.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 5. CDC Flink.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The onboarding workflow is intentionally designed with early validation guardrails to proactively surface prerequisite and governance-related issues before pipeline creation proceeds.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;For Kafka sources, user drop-offs between the “Create Kafka Source” and “Kafka Sink” stages are primarily driven by validation checks such as topic ownership verification and topic activity requirements, for example topics with zero message volume. Additional drop-offs between the “Kafka Sink” and “Create Source Pipeline” stages typically occur when the proposed output table name already exists in the data lake, preventing duplicate table creation.&lt;/li&gt;
  &lt;li&gt;For MySQL sources, drop-offs are mainly associated with unmet database onboarding prerequisites, including credential setup, binlog user configuration, binlog format requirements, and binlog expiration settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In addition, the streamlined self-service experience encourages exploratory usage, allowing teams to familiarize themselves with the onboarding workflow and platform capabilities before fully committing to pipeline creation.&lt;/p&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;The new architecture engineered a custom automation layer that successfully retired the reliance on Kafka Connect and Sprinkler for the data lake, turning artisanal work into a streamlined, one-click experience. This transformation provides a direct boost to developer productivity.&lt;/p&gt;

&lt;p&gt;The key impact metrics are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Onboarding time reduction:&lt;/strong&gt; The time required to set up data pipelines has been dramatically reduced and is now measured in minutes.
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Kafka pipelines:&lt;/strong&gt; approximately 6 minutes.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;MySQL CDC pipelines:&lt;/strong&gt; approximately 3 minutes.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Adoption:&lt;/strong&gt; Since the release, the number of new Kafka and CDC pipelines onboarded in the last year is more than the total number of pipelines onboarded in the previous five years.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;

&lt;p&gt;These enhancements are just one step in our broader vision for optimized and self-service data ingestion. Currently, Flink is the default only for Kafka source pipelines. Flink onboarding for MySQL CDC pipelines is impact- and cost-driven. Our strategic roadmap includes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Next-generation formats:&lt;/strong&gt; We are investigating the adoption of Apache Iceberg as the data lake table format to further improve pipeline SLA and costs, and improve performance.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Seamless schema evolution:&lt;/strong&gt; Schema changes still require some manual effort from pipeline owners, including manually restarting Flink pipelines. In Hugo, we aim to make schema evolution a zero-touch experience by automatically detecting changes, validating compatibility, and updating tables without disruption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;join-us&quot;&gt;Join us&lt;/h2&gt;

&lt;p&gt;Grab is Southeast Asia’s leading superapp, serving over 900 cities across eight countries (Cambodia, Indonesia, Malaysia, Myanmar, the Philippines, Singapore, Thailand, and Vietnam). Through a single platform, millions of users access mobility, delivery, and digital financial services, including ride-hailing, food delivery, payments, lending, and digital banking via GXS Bank and GXBank. Founded in 2012, Grab’s mission is to drive Southeast Asia forward by creating economic empowerment for everyone while delivering sustainable financial performance and positive social impact.&lt;/p&gt;

&lt;p&gt;Powered by technology and driven by heart, our mission is to drive Southeast Asia forward by creating economic empowerment for everyone. If this mission speaks to you, &lt;a href=&quot;https://grb.to/gebapacheflink&quot;&gt;join our team today&lt;/a&gt;!&lt;/p&gt;
</description>
        <pubDate>Fri, 22 May 2026 00:23:00 +0000</pubDate>
        <link>https://engineering.grab.com/one-click-data-ingestion-platform-with-apache-flink</link>
        <guid isPermaLink="true">https://engineering.grab.com/one-click-data-ingestion-platform-with-apache-flink</guid>
        
        <category>Database</category>
        
        <category>Hugo</category>
        
        <category>FlinkSQL</category>
        
        
        <category>Engineering</category>
        
        <category>Data</category>
        
      </item>
    
  </channel>
</rss>
