<?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>Fri, 10 Jul 2026 00:37:07 +0000</pubDate>
    <lastBuildDate>Fri, 10 Jul 2026 00:37:07 +0000</lastBuildDate>
    <generator>Jekyll v4.4.1</generator>
    
      <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>
    
      <item>
        <title>Scaling developer experience: How we improved Android Studio in a large monorepo</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;Long integrated development environment (IDE) sync/indexing times can quietly erode developer productivity, making code navigation sluggish, spiking memory usage, and slowing down Jetpack Compose preview updates, turning the IDE into a bottleneck rather than a helpful tool. For Android engineers working in a large monorepo, this was a daily reality. In this post, we will share how we built a custom Focus plugin that dramatically reduced Android Studio sync times by leveraging our existing investments, such as the Gradle-to-Bazel migration workflow.&lt;/p&gt;

&lt;h2 id=&quot;our-android-monorepo-at-scale&quot;&gt;Our Android monorepo at scale&lt;/h2&gt;

&lt;p&gt;The Grab passenger Android (PAX) repository contains roughly 2,000 Android modules and 11,000,000 lines of code. As the repository grows year over year, a natural consequence of scaling our superapp, which combines ride-hailing, food delivery, payments, and more into a single application, is the increase in time required to build and sync the project.&lt;/p&gt;

&lt;p&gt;What makes this growth especially pronounced today is the shift in how code gets written. Development assisted by artificial intelligence (AI) has enabled engineers to produce more code faster than before. At the same time, non-engineering personnel such as designers, product managers, and other non-technical contributors have started making changes to low-risk features under engineering provision. Together, these two forces are pushing the codebase to grow at its fastest rate ever, which in turn compounds the pressure on every developer’s IDE and build tooling to keep up.&lt;/p&gt;

&lt;p&gt;We previously adopted &lt;a href=&quot;https://engineering.grab.com/how-grab-is-blazing-through-the-super-app-bazel-migration&quot;&gt;Bazel&lt;/a&gt; to speed up incremental and cached builds, but build time was only part of the picture. We intentionally kept Android Studio syncing with Gradle, so developers get fast Bazel builds while the IDE uses the standard Gradle toolchain, thereby preserving compatibility and avoiding the friction and tooling gaps of full Bazel IDE integration. This trade-off gives us the best of both worlds, but it also means Gradle sync remains a first-class concern. Even though Bazel handles the builds, Android Studio still depends on Gradle sync to import the project model that powers IDE features such as code navigation, autocompletion, and error highlighting. That sync process, which evaluates every module declared in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt;, had quietly become a major pain point.&lt;/p&gt;

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

&lt;p&gt;Over time, we noticed a growing number of reports stating that IDE syncs were too slow and memory-intensive. A single full sync could take more than 35 minutes on a cold start. The pain was especially acute after a rebase or branch checkout. Since these operations often modify build configuration files, Android Studio would detect the changes and trigger a full re-sync just to restore basic IDE functionality.&lt;/p&gt;

&lt;p&gt;We conducted a developer experience survey to quantify the issue. From 55 responses, the results painted a clearer picture:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;76% said long sync times significantly or very significantly impacted their productivity.&lt;/li&gt;
  &lt;li&gt;60% were unsatisfied or very unsatisfied with IDE sync time.&lt;/li&gt;
  &lt;li&gt;47% were unsatisfied or very unsatisfied with Compose preview update speed.&lt;/li&gt;
  &lt;li&gt;82% said they would benefit from the option to exclude modules from syncing.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/android-studio-in-monorepo/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Results of developer experience survey.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The survey validated our anecdotal feedback: developers were frustrated. Slow, sluggish IDE performance was eroding productivity and disrupting flow. We set out to determine whether developers really needed to load every module to work on just one.&lt;/p&gt;

&lt;h2 id=&quot;investigation&quot;&gt;Investigation&lt;/h2&gt;

&lt;h3 id=&quot;root-cause&quot;&gt;Root cause&lt;/h3&gt;

&lt;p&gt;The root cause was straightforward: module count. With roughly 2,000 modules in the codebase, a full sync required Gradle to configure every single module, including parsing build files, resolving dependencies, and generating IDE project models, regardless of whether the developer actually needed them. A developer working on the Payments feature still had to wait for Gradle to process Food, Transport, Mart, and every other module. The configuration time and resulting memory consumption grew roughly in proportion to module count, and the count kept rising.&lt;/p&gt;

&lt;h3 id=&quot;exploring-community-solutions&quot;&gt;Exploring community solutions&lt;/h3&gt;

&lt;p&gt;We looked at existing solutions in the Android community. One promising candidate was the &lt;a href=&quot;https://github.com/dropbox/focus&quot;&gt;Focus plugin from Dropbox&lt;/a&gt;. Here’s how the Focus plugin works:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;The developer runs a Gradle command to focus on a specific module (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew :module:focus&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;The Gradle task calculates the dependency graph, generates a separate focused settings file, and writes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.focus&lt;/code&gt; marker file that tells Gradle to use it instead of the full project settings.&lt;/li&gt;
  &lt;li&gt;The developer syncs the IDE, which now only configures the focused modules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach works because instead of syncing the entire repository, the developer only configures the module they are working on, along with its required dependencies. Everything else is excluded.&lt;/p&gt;

&lt;p&gt;For example, if you are working on the Payments module, only Payments and its dependency chain get loaded. Food, Transport, and Mart modules are excluded entirely.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/android-studio-in-monorepo/figure-2.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2. Focus mode sync vs. full sync.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Depending on the size of the target module, this approach can cut the number of loaded modules by 50% or more, especially with a well-structured modularization architecture. We wanted to adopt this approach and saw an opportunity to improve it further by leveraging our existing Gradle-to-Bazel migration workflow.&lt;/p&gt;

&lt;h2 id=&quot;our-solution-building-a-custom-focus-plugin&quot;&gt;Our solution: Building a custom Focus plugin&lt;/h2&gt;

&lt;p&gt;The Dropbox Focus plugin was a great starting point, but it introduced several friction points in our setup:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;We would need to move all non-essential declarations from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt; into a separate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings-all.gradle&lt;/code&gt; file.&lt;/li&gt;
  &lt;li&gt;We would need guardrails to ensure new modules are declared in the correct file.&lt;/li&gt;
  &lt;li&gt;Most critically, focusing on a module requires running a Gradle task (e.g., &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew :module:focus&lt;/code&gt;), which itself goes through Gradle’s configuration phase and adds a noticeable delay before a developer can even start an IDE sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We set out to address each of these issues.&lt;/p&gt;

&lt;h3 id=&quot;challenge-1-eliminating-the-configuration-phase&quot;&gt;Challenge 1: Eliminating the configuration phase&lt;/h3&gt;

&lt;p&gt;The Dropbox Focus plugin recalculates the dependency graph every time a developer runs the focus command. This means every Focus operation pays the cost of Gradle’s configuration phase, parsing every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build.gradle&lt;/code&gt; file in the project to resolve the full dependency tree.&lt;/p&gt;

&lt;p&gt;We realized we already had this information. Our build infrastructure includes Grazel, which migrates Gradle build files to their Bazel equivalents via a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;migrateToBazel&lt;/code&gt; task, and our Continuous Integration (CI) validations ensure that both are aligned. This task already traverses the full dependency graph for migration purposes.&lt;/p&gt;

&lt;p&gt;Our insight: generate a dependency graph as a static file during &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;migrateToBazel&lt;/code&gt; and reuse it for focus operations.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/android-studio-in-monorepo/figure-3.png&quot; alt=&quot;&quot; style=&quot;width:60%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3. Focus flow vs. Grab’s customized focus flow.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;By pre-computing and persisting the dependency graph, we skip the Gradle configuration phase entirely. The focus operation becomes a fast, local file lookup instead of a lengthy Gradle computation. The developer simply selects their module and syncs.&lt;/p&gt;

&lt;p&gt;The dependency graph is stored as a JSON file, which is lightweight and fast to read. The trade-off is that it requires a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;migrateToBazel&lt;/code&gt; run to stay up-to-date. When creating a new module or changing module dependencies, developers need to rerun &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./gradlew migrateToBazel&lt;/code&gt; to regenerate the graph. We accepted this because developers already had to run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;migrateToBazel&lt;/code&gt; before merging to master (to ensure Bazel files are current). The graph stays fresh as part of their existing workflow, and no extra step is required.&lt;/p&gt;

&lt;h3 id=&quot;challenge-2-minimizing-developer-friction-with-a-gradle-plugin&quot;&gt;Challenge 2: Minimizing developer friction with a Gradle plugin&lt;/h3&gt;

&lt;p&gt;We did not want to introduce a process that adds cognitive load. Migrating all module declarations to a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt; file would require every team to change their workflow. Instead, we adopted a more elegant approach.&lt;/p&gt;

&lt;h4 id=&quot;the-include-shadow-trick&quot;&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include&lt;/code&gt; shadow trick&lt;/h4&gt;

&lt;p&gt;In a standard Android project, modules are declared in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt; using the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include&lt;/code&gt; function:&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;include &apos;app&apos;
include &apos;payment&apos;
include &apos;food&apos;
// ... hundreds more
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include&lt;/code&gt; function is part of the Gradle Settings API. In Groovy, you can define a local closure variable with the same name as an existing method. Since Groovy resolves local variables before delegate methods, the closure effectively shadows the original include method and all subsequent include calls in the script invoke the closure instead.&lt;/p&gt;

&lt;p&gt;We created a custom Gradle plugin with a focusInclude function that decides whether to include or exclude a module based on the current focus configuration. By adding just three lines to the top of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt;, we redirect all include calls through our plugin:&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;// After applying the focus plugin in the buildscript block
def include = { module -&amp;gt;
 com.grab.focus.GradleFocusPluginKt.focusInclude(settings, module)
}
include &apos;app&apos;
include &apos;payment&apos;
include &apos;food&apos;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The rest of the file remains untouched. Every existing include call now passes through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;focusInclude&lt;/code&gt;, which checks whether the module should be loaded based on the developer’s focus selection. If no focus is active, all modules are included as usual with zero behavior change.&lt;/p&gt;

&lt;p&gt;This approach meant zero migration effort for feature teams. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings.gradle&lt;/code&gt; file stays as-is, and the plugin integrates seamlessly.&lt;/p&gt;

&lt;h4 id=&quot;early-implementation-property-based-focus&quot;&gt;Early implementation: Property-based focus&lt;/h4&gt;

&lt;p&gt;In the early days of this plugin’s development, the way to specify focus modules was via a Gradle property in the command line:&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;./gradlew build -Pmodules-to-sync=&quot;:app,:payment&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;focusInclude&lt;/code&gt; function reads this Gradle property. If present, it activates focus mode and only includes the specified modules (and their transitive dependencies resolved from the graph file). If absent, all modules are included normally.&lt;/p&gt;

&lt;h3 id=&quot;challenge-3-making-it-seamless-with-an-android-studio-plugin&quot;&gt;Challenge 3: Making it seamless with an Android Studio plugin&lt;/h3&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/android-studio-in-monorepo/figure-4.png&quot; alt=&quot;&quot; style=&quot;width:60%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 4. User flow.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Manually passing Gradle properties on the command line was functional but not ideal. We needed a better developer experience. The Gradle property approach opened the door to IDE integration; this led to an Android Studio plugin (an IntelliJ plugin) being built that automates the entire flow through a user interface (UI):&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Module selection&lt;/strong&gt;: The plugin presents a list of all available modules, parsed from the pre-computed dependency graph file. Developers select which modules they want to work on.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Dependency count indicator&lt;/strong&gt;: Since we have the full dependency graph, the plugin displays how many transitive dependencies each module requires. This gives developers immediate visibility into module “weight” and encourages teams to keep their modules lean.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Automatic argument injection&lt;/strong&gt;: The plugin uses two IntelliJ Gradle extension points to inject the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-Pmodules-to-sync&lt;/code&gt; property: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GradleResolverExtension&lt;/code&gt; that adds the argument during project sync, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GradleTaskManagerExtension&lt;/code&gt; that injects it before any Gradle task execution (including Compose preview builds). The developer just clicks sync; the plugin handles the rest.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/android-studio-in-monorepo/figure-5.png&quot; alt=&quot;&quot; style=&quot;width:60%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 5. Example of Automatic Argument Injection.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Beyond the core functionality, we added several usability enhancements to the plugin:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Indirect focus indicator&lt;/strong&gt;: Modules that will be synced as a transitive dependency of a focused module are marked as “indirectly focused,” giving developers visibility into exactly what will be loaded.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Search and filtering&lt;/strong&gt;: With hundreds of modules, finding the right one matters. The plugin supports fuzzy matching and regular expression (regex) search to quickly narrow down the module list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sort by dependency count&lt;/strong&gt;: Modules can be sorted by name or by dependency count, making it easy to spot the heaviest modules at a glance.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Status bar widget&lt;/strong&gt;: A persistent “Focus: X/Y” indicator in the IDE status bar shows how many modules are currently focused out of the total, with a click-through to the Focus tool window.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;State persistence&lt;/strong&gt;: The developer’s focus selection is saved and restored between IDE sessions, so they do not need to reselect modules after restarting Android Studio.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;encouraging-lean-module-architecture&quot;&gt;Encouraging lean module architecture&lt;/h3&gt;

&lt;p&gt;An unplanned but welcome side effect of the focus plugin was that it nudged teams toward a cleaner module architecture. With dependency counts now visible in the IDE, developers became more aware of their module’s size, which in turn encouraged a clearer separation between interface and implementation.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Interface module&lt;/strong&gt; (e.g., &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:payment-api&lt;/code&gt;): Contains only the public API definitions (interfaces, data classes, contracts). This is the module that other teams depend on. Because it has no implementation details, it carries very few transitive dependencies.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Implementation module&lt;/strong&gt; (e.g., &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:payment-impl&lt;/code&gt;): Contains the actual implementation of those interfaces. This module typically has a larger dependency footprint, but only the owning team needs to load it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By depending on the interface module rather than the implementation module, teams avoid pulling in a large tree of transitive dependencies. This keeps the dependency count low for consumers, which directly translates to faster focus sync times and leaner Compose preview builds.&lt;/p&gt;

&lt;h2 id=&quot;how-we-measure&quot;&gt;How we measure&lt;/h2&gt;

&lt;h3 id=&quot;instrumentation-the-pax-ide-plugin&quot;&gt;Instrumentation: The PAX IDE plugin&lt;/h3&gt;

&lt;p&gt;The PAX IDE plugin is a mandatory install for every PAX Android engineer in Grab. This gives us a consistent, organization-wide data collection baseline without requiring any opt-in. The plugin registers four IntelliJ Platform listeners that automatically capture metrics on every relevant IDE event:&lt;/p&gt;

&lt;table border=&quot;1&quot; cellpadding=&quot;8&quot; cellspacing=&quot;10&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th align=&quot;left&quot;&gt;IntelliJ API&lt;/th&gt;
      &lt;th align=&quot;left&quot;&gt;What it tracks&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;GradleSyncListenerWithRoot&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Sync time&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;ProjectIndexingActivityHistoryListener&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Indexing time&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;ProjectIndexingActivityHistoryListener&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Scanning time&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;PerformanceListener&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;IDE freezes&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Each metric event is enriched with shared context captured at event time: IDE version and build number, heap memory usage, focus state (enabled/disabled, number of focused modules), Operating System (OS) info, and project name. This means every data point is automatically segmented by whether focus mode was active, which is exactly what we need for before/after comparisons.&lt;/p&gt;

&lt;h3 id=&quot;what-each-metric-captures&quot;&gt;What each metric captures&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Sync time&lt;/strong&gt;: We implement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GradleSyncListenerWithRoot&lt;/code&gt; and calculate wall-clock duration from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;syncStarted()&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;syncSucceeded()&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;syncFailed()&lt;/code&gt;. This covers the full Gradle configuration, dependency resolution, and IDE model generation phase.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Indexing time&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProjectIndexingActivityHistoryListener.onFinishedDumbIndexing()&lt;/code&gt; provides a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProjectDumbIndexingHistory&lt;/code&gt; object. We read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history.times.totalUpdatingTime&lt;/code&gt;, the time IntelliJ spent updating its symbol index after the sync.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Scanning time&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProjectIndexingActivityHistoryListener.onFinishedScanning()&lt;/code&gt; provides a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProjectScanningHistory&lt;/code&gt; object. We read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history.times.totalUpdatingTime&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history.times.scanningType&lt;/code&gt; (full vs. partial) for additional segmentation.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;IDE freezes&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PerformanceListener.uiFreezeFinished(durationMs)&lt;/code&gt; is called by the platform whenever the Event Dispatch Thread (EDT) is blocked long enough to be classified as a freeze. The duration arrives directly as a parameter.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;IDE memory usage&lt;/strong&gt;: Captured at the moment of each metric event via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Runtime.getRuntime()&lt;/code&gt;. Captures used memory (totalMemory - freeMemory) and max heap. Attached to every event as part of the shared context.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;IDE version&lt;/strong&gt;: From &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ApplicationInfo.getInstance()&lt;/code&gt;, captures version name, full version string, and build number. Also attached to every event, enabling per-version breakdowns.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;survey&quot;&gt;Survey&lt;/h3&gt;

&lt;p&gt;After each successful sync, the plugin triggers an in-IDE notification prompting developers to fill out a short survey. The notification respects developer attention; it uses a weekly reset cycle with a “Don’t remind me again” option that appears after the second prompt. These periodic qualitative check-ins complement the telemetry data and help surface pain points that raw numbers alone may not capture.&lt;/p&gt;

&lt;h3 id=&quot;establishing-the-baseline&quot;&gt;Establishing the baseline&lt;/h3&gt;

&lt;p&gt;The plugin collects &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;focus_enabled&lt;/code&gt; on every event. Therefore, baseline numbers come directly from the same pipeline; they are simply the subset of metric events where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;focus_enabled = false&lt;/code&gt;. This means the before/after comparison is an apples-to-apples measurement from the same instrumentation, same engineers, same codebase, with no separate manual benchmarking required.&lt;/p&gt;

&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;

&lt;h3 id=&quot;compose-preview-build&quot;&gt;Compose preview build&lt;/h3&gt;

&lt;p&gt;The focus approach also improved Jetpack Compose preview builds. Compose previews require a module build to render, and with fewer modules loaded, the IDE has significantly less indexing overhead. A typical UI module has just 5–10 local dependencies. With the focus plugin, a developer configures only those modules instead of all 2,000. Developers consistently report that Compose previews feel significantly more responsive in focus mode.&lt;/p&gt;

&lt;p&gt;As a best practice, we recommend that teams separate their UI into dedicated modules containing only composable functions and minimal dependencies. This maximizes the benefit of focus mode for preview builds.&lt;/p&gt;

&lt;h3 id=&quot;memory-usage&quot;&gt;Memory usage&lt;/h3&gt;

&lt;p&gt;In focus mode, excluded modules are not configured by Gradle and not indexed by the IDE, significantly reducing both build-process and editor memory consumption from approximately 10 GB down to 2 GB. This frees up memory for Bazel builds and other tooling. Developers reported fewer freezes, faster code navigation, and more responsive autocompletion.&lt;/p&gt;

&lt;h3 id=&quot;sync-time&quot;&gt;Sync time&lt;/h3&gt;

&lt;p&gt;We observed a dramatic reduction in per-sync IDE sync time. A full sync previously took around 26 minutes at the 95th percentile (p95). With the Focus plugin, sync times dropped to under 2 minutes for typical feature work. The p95 remains higher for modules with deep dependency trees, but in practice, sync times vary significantly depending on module size. A typical UI module with 5 to 10 dependencies syncs in roughly 2 minutes, while heavier modules with deep dependency graphs take longer. For most developers working on focused feature work, the improvement is dramatic.&lt;/p&gt;

&lt;h3 id=&quot;tradeoffs&quot;&gt;Tradeoffs&lt;/h3&gt;

&lt;p&gt;Focus mode does come with limitations. IDE features like “Find Usages” and cross-module refactoring only cover the focused modules; developers occasionally need to expand their focus set or temporarily switch to a full sync for repo-wide operations. In practice, this has been a minor inconvenience compared to the productivity gained.&lt;/p&gt;

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

&lt;p&gt;IDE sync time is one of those problems that slowly degrades the developer experience without a single dramatic breaking point.&lt;/p&gt;

&lt;p&gt;Our solution combined three key ideas:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reuse existing infrastructure&lt;/strong&gt;: By generating the dependency graph during &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;migrateToBazel&lt;/code&gt;, we eliminated the expensive Gradle configuration phase without adding a new build step.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Minimize adoption friction&lt;/strong&gt;: The Groovy include shadow trick let us integrate the focus mechanism with just three lines of code, requiring zero changes from feature teams.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Invest in user experience (UX)&lt;/strong&gt;: The Android Studio plugin turned a manual, error-prone process into a one-click operation with useful module health indicators.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The results spoke for themselves. IDE sync time dropped from 35 minutes to under 1 minute &lt;em&gt;(depending on module size)&lt;/em&gt;. IDE memory consumption fell from 10 GB down to 2 GB, freeing up headroom for Bazel builds to run alongside the IDE. Compose preview update times improved significantly due to reduced indexing overhead. And adoption was frictionless. Engineers went from a manual, multi-step process to a simple Select → Focus → Sync flow with native IntelliJ integration.&lt;/p&gt;

&lt;p&gt;As the codebase continues to grow, accelerated by AI-assisted development and a broader contributor base, we are also investing in guardrails to keep quality in check. An area we are actively exploring is using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;skills.md&lt;/code&gt; to guide AI coding agents when they generate new modules, encoding architectural conventions and dependency rules directly into the context that AI tools consume. This helps ensure that AI-generated code lands in the right shape from the start, rather than accumulating structural debt that compounds the sync and build problems described above.&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://grb.to/gebandriodstudio&quot;&gt;join our team&lt;/a&gt; today!&lt;/p&gt;

</description>
        <pubDate>Fri, 15 May 2026 00:23:00 +0000</pubDate>
        <link>https://engineering.grab.com/how-we-improved-android-studio-in-large-monorepo</link>
        <guid isPermaLink="true">https://engineering.grab.com/how-we-improved-android-studio-in-large-monorepo</guid>
        
        <category>Engineering</category>
        
        <category>Android</category>
        
        <category>Performance</category>
        
        <category>Monorepo</category>
        
        
        <category>Engineering</category>
        
        <category>Android</category>
        
      </item>
    
      <item>
        <title>Enhancing Flink deployment with shadow testing</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;Ensuring the reliability of Apache Flink deployments in Grab is crucial for the availability of our business-critical, real-time applications. While all applications are tested in a staging environment before getting promoted to the production environment, there is still a class of issues that can only surface when deploying in the production environment, e.g.:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The new version of the application is unable to cope with the volume or the nature of production traffic.&lt;/li&gt;
  &lt;li&gt;The new version of the application is unable to resume from a production checkpoint or savepoint taken by the previous version of the application.&lt;/li&gt;
  &lt;li&gt;Certain environment-specific dependencies or configurations are malfunctioning or misconfigured.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When an application faces such issues upon deployment in production, our in-house deployment system automatically rolls it back after 10 minutes of observation, leading to a downtime of the application for about the same duration.&lt;/p&gt;

&lt;p&gt;In this article, we will describe how Grab’s data streaming team (Coban) has enriched the traditional deployment pipeline for Flink applications with a &lt;strong&gt;Shadow Testing stage&lt;/strong&gt; that eliminates this downtime during deployment failures, enhancing the availability of our Flink applications during this critical moment of their lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shadow Testing&lt;/strong&gt; is a testing technique whereby a new version of an application (&lt;em&gt;Shadow&lt;/em&gt;) is deployed in parallel with the current version of the application (&lt;em&gt;Main&lt;/em&gt;), but without impacting it. It involves replicating production data to the new version of the application and comparing its behavior with the current version of the application to identify potential issues and regressions.&lt;/p&gt;

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

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/shadow-testing/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Overall architecture of Shadow Testing.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;We integrated Shadow Testing directly into the production environment, alongside the Main application (1). The Shadow application is deployed next to it via the same deployment process (2). An environment variable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isShadow=true&lt;/code&gt; as well as a distinct &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jobID&lt;/code&gt; are injected for runtime differentiation, enabling the Shadow application to produce its results to distinct, isolated sinks that do not interfere with those of the Main application (3).&lt;/p&gt;

&lt;h2 id=&quot;deployment-flow&quot;&gt;Deployment flow&lt;/h2&gt;

&lt;p&gt;Shadow Testing is embedded within our normal Flink deployment pipeline to make it a seamless experience for the users of our platform.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/shadow-testing/figure-2.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2. Deployment flow diagram.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;The deployment flow is as follows.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;A user triggers a deployment of their Flink application in Grab’s in-house deployment tool. At this step, they decide whether they want to enable Shadow Testing for this particular deployment.&lt;/li&gt;
  &lt;li&gt;The deployment pipeline validates the input parameters provided by the user.&lt;/li&gt;
  &lt;li&gt;If the user has not opted for Shadow Testing, the deployment flow directly jumps to step 8 and deploys the latest version to the Main application. However, if the user has enabled Shadow Testing, the deployment flow first goes through the Shadow Testing stages described in steps 4 to 7.&lt;/li&gt;
  &lt;li&gt;The Shadow Kubernetes manifest is baked with its set of distinctive parameters:
    &lt;ul&gt;
      &lt;li&gt;The application name is prefixed with &lt;em&gt;shadow-&lt;/em&gt; which propagates to all the Kubernetes objects that are part of the Shadow application&lt;/li&gt;
      &lt;li&gt;An environment variable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isShadow&lt;/code&gt; is injected and set to &lt;em&gt;true&lt;/em&gt;. It instructs the Shadow application to produce its results to the shadow sinks.&lt;/li&gt;
      &lt;li&gt;A distinct Job ID is attributed&lt;/li&gt;
      &lt;li&gt;The target Kubernetes namespace is overridden with a &lt;em&gt;shadow&lt;/em&gt; namespace&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;The Shadow application is deployed into the &lt;em&gt;shadow&lt;/em&gt; Kubernetes namespace.&lt;/li&gt;
  &lt;li&gt;The Shadow application runs for a configured period of 1 hour by default to reach a steady state. The status of the job manager is monitored to determine the success of the Shadow Testing. If the Shadow application is stable, the Shadow Testing is considered successful.&lt;/li&gt;
  &lt;li&gt;The user is prompted to continue with the deployment of the Main application.&lt;/li&gt;
  &lt;li&gt;The Kubernetes manifest of the Main application is baked with its standard parameters and the environment variable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isShadow&lt;/code&gt; is set to &lt;em&gt;false&lt;/em&gt;.&lt;/li&gt;
  &lt;li&gt;The Main application is deployed in its standard Kubernetes namespace.&lt;/li&gt;
  &lt;li&gt;After 10 minutes of observation, the deployment pipeline determines if the Main application is healthy by querying the status of its job manager. If it is healthy, the Main application is considered successfully deployed. Otherwise, the deployment pipeline automatically triggers a rollback to the previous version.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;During the deployment, the user can leverage our standard observability stack to monitor the behavior of the Shadow application. For example, in the case of an Apache Kafka sink, they can compare the number of messages produced by the Main and Shadow applications.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/shadow-testing/figure-3.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3. Tracking of the Kafka messages.in_rate metric for the respective Kafka sink topics of the Main application (purple) and Shadow application (blue) at the beginning of the Shadow deployment stage.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;Besides, our standard Datadog dashboard that comes with each application can conveniently be toggled to view the metrics of the respective Shadow application.&lt;/p&gt;

&lt;h2 id=&quot;connector-implementation&quot;&gt;Connector implementation&lt;/h2&gt;

&lt;p&gt;Our standard sink and source connectors, provided by our platform, ensure the absence of interference with the Main application during Shadow Testing. For example, Kafka source connectors use distinct consumer group IDs, while the various sink connectors direct the data to dedicated shadow sinks.&lt;/p&gt;

&lt;p&gt;The Flink application evaluates the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isShadow&lt;/code&gt; environment variable to set up the connectors at runtime.&lt;/p&gt;

&lt;div class=&quot;language-java 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;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isShadow&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;){&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Shadow Testing operation&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Normal operation&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The following table shows how some typical connectors are dynamically configured if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isShadow=true&lt;/code&gt;:&lt;/p&gt;

&lt;table class=&quot;table&quot; border=&quot;1&quot; style=&quot;border-collapse: collapse;&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Type&lt;/th&gt;
      &lt;th&gt;Connector&lt;/th&gt;
      &lt;th&gt;Dynamic configuration&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Source&lt;/td&gt;
      &lt;td&gt;Kafka&lt;/td&gt;
      &lt;td&gt;The consumer group ID for the Shadow application is suffixed with &lt;em&gt;-shadow&lt;/em&gt;. This is crucial so as to consume a full copy of the data stream without interfering with the Main application. &lt;br /&gt; Main application: &lt;code&gt;consumerGroup = &amp;lt;application_name&amp;gt;&lt;/code&gt; &lt;br /&gt; Shadow application: &lt;code&gt;consumerGroup = &amp;lt;application_name&amp;gt;-shadow&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Source&lt;/td&gt;
      &lt;td&gt;Change Data Capture&lt;/td&gt;
      &lt;td&gt;The Server ID range of &lt;a href=&quot;https://debezium.io/&quot;&gt;Debezium&lt;/a&gt; is shifted to the next non-overlapping range of the same size. This enables the Shadow application to get a full copy of the database binlog stream without interfering with the Main application. Note that the misleading &lt;em&gt;Server ID&lt;/em&gt; naming is because Debezium acts as a pseudo-replica of the database server. &lt;br /&gt; Main application: &lt;code&gt;serverId = 1001-2000&lt;/code&gt; &lt;br /&gt; Shadow application: &lt;code&gt;serverId = 2001 - 3000&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sink&lt;/td&gt;
      &lt;td&gt;Kafka&lt;/td&gt;
      &lt;td&gt;The cluster endpoint is replaced with that of a Kafka cluster dedicated to Shadow Testing, set up with &lt;em&gt;auto.create.topics.enable=true&lt;/em&gt; and 8h retention. &lt;br /&gt; Main application: &lt;code&gt;brokers = &amp;lt;flink-kafka&amp;gt;:9092&lt;/code&gt; &lt;br /&gt; Shadow application: &lt;code&gt;brokers = &amp;lt;flink-kafka-shadow&amp;gt;:9092&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sink&lt;/td&gt;
      &lt;td&gt;S3&lt;/td&gt;
      &lt;td&gt;The S3 bucket name is replaced with that of a bucket dedicated to Shadow Testing, set up with a 7-day retention lifecycle policy.&lt;br /&gt;  Main application: &lt;code&gt;s3://&amp;lt;flink-s3&amp;gt;/&amp;lt;application_name&amp;gt;&lt;/code&gt; &lt;br /&gt;  Shadow application: &lt;code&gt;s3://&amp;lt;flink-s3-shadow&amp;gt;/&amp;lt;application_name&amp;gt;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sink&lt;/td&gt;
      &lt;td&gt;Metrics&lt;/td&gt;
      &lt;td&gt;The StatsD prefix configuration is overridden. A &lt;em&gt;shadow.&lt;/em&gt; prefix is added. &lt;br /&gt;  Main application: &lt;code&gt;flink.&amp;lt;application_name&amp;gt;.&amp;lt;metric_name&amp;gt;&lt;/code&gt;&lt;br /&gt; Shadow application: &lt;code&gt;shadow.flink.&amp;lt;application_name&amp;gt;.&amp;lt;metric_name&amp;gt;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sink&lt;/td&gt;
      &lt;td&gt;Logs&lt;/td&gt;
      &lt;td&gt;The &lt;em&gt;Shadow&lt;/em&gt; Kubernetes manifest prefixes the Shadow application name with &lt;em&gt;shadow-&lt;/em&gt;. The resulting name becomes available as a field in Kibana, enabling discriminated filtering. This tweak is done at the Kubernetes manifest level, not at the Flink application level.&lt;br /&gt; Main application: &lt;code&gt;app_name = &amp;lt;application_name&amp;gt;&lt;/code&gt; &lt;br /&gt; Shadow application: &lt;code&gt;app_name = shadow-&amp;lt;application_name&amp;gt;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

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

&lt;p&gt;Our Shadow Testing framework represents a meaningful step forward in enhancing the reliability of our Flink applications during deployment. By leveraging and enriching the existing components of our platform, we have created a robust system that enables our users to confidently increase their Deployment Frequency and reduce their Change Failure Rate.&lt;/p&gt;

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

&lt;p&gt;To drive wider adoption, we intend to support more source and sink connectors. By expanding the range of supported connectors, we could empower teams to leverage Shadow Testing across a broader spectrum of applications.&lt;/p&gt;

&lt;p&gt;For connectors that are less frequently used, we consider implementing a no-op approach combined with metrics collection to expose a minimal set of actionable data points.&lt;/p&gt;

&lt;p&gt;We will remain focused on making Shadow Testing accessible, scalable, and adaptable to various applications. Stay tuned as we continue to push the boundaries of innovation and deliver solutions that enhance reliability and efficiency across our systems.&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/gebios&quot;&gt;join our team today&lt;/a&gt;!&lt;/p&gt;
</description>
        <pubDate>Thu, 07 May 2026 00:23:00 +0000</pubDate>
        <link>https://engineering.grab.com/enchancing-flink-shadow-testing</link>
        <guid isPermaLink="true">https://engineering.grab.com/enchancing-flink-shadow-testing</guid>
        
        <category>Database</category>
        
        <category>Testing</category>
        
        <category>FlinkSQL</category>
        
        
        <category>Engineering</category>
        
        <category>Data</category>
        
      </item>
    
      <item>
        <title>Data Mesh at Grab (Part II): The foundational tools behind certification</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/signals-market-place&quot;&gt;Part I&lt;/a&gt;, we discussed why Grab is investing in a data mesh, referred to as the &lt;strong&gt;Signals Marketplace&lt;/strong&gt; within Grab, as part of our evolving data culture. We also explained how data certification aids teams in reliably reusing data across different domains. However, cultural change doesn’t occur through principles alone; it happens when tools reshape people’s daily behaviors. Therefore, it is crucial for us to develop effective platforms that integrate these practices.&lt;/p&gt;

&lt;p&gt;This follow-up focuses on these platforms that make certification work at Grab:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Hubble&lt;/strong&gt; – the central metadata management platform with a built-in certification engine.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Genchi&lt;/strong&gt; – the data quality observability platform.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Data Contract Registry&lt;/strong&gt; – the central service for managing data contracts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, these platforms turn data mesh principles into an operational system that scales across hundreds of thousands of datasets, streams, attributes, and metrics.&lt;/p&gt;

&lt;h2 id=&quot;hubble-the-data-discovery-and-governance-layer&quot;&gt;Hubble: The data discovery and governance layer&lt;/h2&gt;

&lt;p&gt;Hubble is Grab’s central metadata management platform and data catalog for all data assets, including datasets, dashboards, metrics, Machine learning (ML) models, and more. Built on top of the open‑source &lt;a href=&quot;https://datahub.com/&quot;&gt;DataHub&lt;/a&gt; and heavily extended for Grab’s needs, it is the discovery and governance layer for the Signals Marketplace.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-1.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 1. Hubble cataloging data assets across various data platforms in Grab.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;what-hubble-does-for-data-mesh&quot;&gt;What Hubble does for data mesh&lt;/h3&gt;

&lt;p&gt;For certification and data mesh, Hubble provides a few key capabilities:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Search and discovery&lt;/strong&gt;: Data analysts/scientists, engineers, and product managers use Hubble to search the rich swath of data assets, then inspect schemas, documentation, lineage, and usage statistics in one place. This replaces back-channel questions and tribal knowledge with a single, self-serve catalog.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Ownership and domains&lt;/strong&gt;: Every asset is tied to a domain and explicit technical and business owners. This enforces the domain ownership model that Signals Marketplace depends on. Producers are clearly accountable for the quality and lifecycle of the data products they publish.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Data contracts and documentation&lt;/strong&gt;: Data contracts, classifications, and rich documentation live as structured metadata attached to each asset, not scattered across wikis and slide decks. Producers and consumers share the same source of truth when they ask questions like, “what does this table guarantee?”.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Lineage and impact analysis&lt;/strong&gt;: Hubble provides table, column, and metric-level lineage so owners can see which teams and pipelines depend on their data before making breaking changes or deprecations. Instead of guessing, they can answer “what will I break?” with a single click.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Certification status&lt;/strong&gt;: The familiar “Hubble green tick” turns trust into a first-class signal in the catalog. Assets that meet Grab’s certification criteria are clearly marked with a drill-down view of which criteria are satisfied (ownership, documentation, contracts, quality tests, upstream certification) and which are still missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a consumer’s point of view, this collapses a lot of uncertainty into a simple workflow: search → filter to “certified” → pick the most suitable asset. They don’t need to reverse-engineer reliability from table names and hearsay.&lt;/p&gt;

&lt;h3 id=&quot;hubbles-system-architecture&quot;&gt;Hubble’s system architecture&lt;/h3&gt;

&lt;p&gt;The &lt;a href=&quot;https://datahub.com/&quot;&gt;open-source DataHub&lt;/a&gt; architecture is fundamentally event-driven and designed for high extensibility, moving away from the “passive” catalogs of the past, towards a “living” metadata graph. DataHub models everything as an &lt;em&gt;Entity&lt;/em&gt; (e.g., a Dataset), composed of multiple &lt;em&gt;Aspects&lt;/em&gt; (atomic versioned metadata like SchemaMetadata or Ownership), connected by &lt;em&gt;Relationships&lt;/em&gt; (e.g., DownstreamOf).&lt;/p&gt;

&lt;p&gt;Since introducing DataHub as Grab’s central data catalog in 2022, we’ve tailored it to Grab’s specific needs while continuously rebasing onto the latest open-source DataHub releases. This lets us adopt new capabilities from the community quickly, contribute improvements back, and evolve Hubble without forking away from the main project.&lt;/p&gt;

&lt;p&gt;On top of the DataHub foundation, Hubble ingests metadata from Grab’s source platforms in two ways. Source systems either push changes as they happen, or Hubble periodically pulls metadata via Airflow jobs into the central metadata service that exposes GraphQL and REST APIs. Every change is then published to Kafka as metadata events (low-level change logs for indexers and audits, and higher-level semantic events for workflows), keeping Hubble’s search and lineage indices fresh and allowing downstream integrations like certification, deprecation notices, and governance automation to react to metadata changes in near real time.&lt;/p&gt;

&lt;p&gt;This architecture is crucial for a data mesh because metadata evolves more rapidly than organizational changes. As domains change, tables are relocated, or pipelines are refactored, Hubble continuously updates, allowing certification to keep pace without the need for manual re-audits.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-2.png&quot; alt=&quot;&quot; style=&quot;width:100%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 2. Hubble’s system architecture.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;computational-certification-via-the-certification-engine-on-hubble&quot;&gt;Computational certification via the certification engine on Hubble&lt;/h3&gt;

&lt;p&gt;A data asset’s certification state is not a manual label, it is computed by an event-driven certification engine built on the &lt;a href=&quot;https://docs.datahub.com/docs/actions&quot;&gt;DataHub Actions&lt;/a&gt; framework. As source platforms for tables, streams, metrics, and user attributes push metadata into Hubble, every change becomes a metadata event. The engine subscribes to these events and re-evaluates the certification state of the data asset based on the predetermined certification criteria.&lt;/p&gt;

&lt;p&gt;Conceptually, assets move between four states:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Uncertified&lt;/strong&gt;: Never met all criteria.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Certified&lt;/strong&gt;: Asset itself meets all required criteria.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CertifiedPlus&lt;/strong&gt;: Stricter conditions than Certified, requiring both the asset and all of its upstreams to meet the criteria.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Revoked&lt;/strong&gt;: Previously certified but now out of compliance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state diagram below captures how assets move between these states as metadata and upstream health change.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-3.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 3. Certification state diagram.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;While each asset type can add its own nuances (for example, metrics and attributes may also require explicit endorsement from business data owners), the core certification criteria are consistent:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Ownership and domain&lt;/strong&gt;: Clear domain assignment plus accountable technical and business data owners.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Documentation and semantics&lt;/strong&gt;: Table/metric documentation and, where relevant, column-level descriptions so consumers understand what the data means.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Lineage and upstream trust&lt;/strong&gt;: For stronger levels like CertifiedPlus, upstream lineage must be present, and upstream assets must themselves be certified.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Contracts and runtime quality&lt;/strong&gt;: Linked data contracts that spell out expectations, backed by required Genchi tests for freshness, volume/completeness, schema stability, and critical business checks.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Governance signals&lt;/strong&gt;: No conflicting deprecation flags or policy violations (for example, missing required classifications on sensitive data).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an asset satisfies the base criteria, the engine writes a certification aspect back into Hubble and surfaces it as a green tick; if the asset later falls out of compliance, certification is revoked, and downstream assets are re-evaluated as needed. This is because all certification changes are stored as time-series metadata and driven by events rather than a one-off checklist; certification becomes a continuous, metadata-driven process that keeps pace as the entity evolves.&lt;/p&gt;

&lt;h2 id=&quot;genchi-the-data-quality-observability-layer&quot;&gt;Genchi: The data quality observability layer&lt;/h2&gt;

&lt;p&gt;Genchi is Grab’s in-house, self-service data quality observability platform. It allows teams to define and run data quality tests on their datasets, receive alerts when something goes wrong, and integrate those tests directly into data contracts so that issues are caught and contained before they impact downstream consumers.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-4.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 4. Genchi job configuration page, where dataset owners enroll a table and set up freshness, volume, schema, and other data quality tests for it.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;what-genchi-does-for-data-mesh&quot;&gt;What Genchi does for data mesh&lt;/h3&gt;

&lt;p&gt;In a data mesh, every domain is responsible for the quality of the data products it publishes. Genchi is the guardrail that makes that responsibility practical at scale. At its core, Genchi turns “good data” into clear, testable pillars that a data asset must satisfy:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Freshness and timeliness&lt;/strong&gt;: Is the data recent enough to trust? Genchi runs data freshness and pipeline-freshness checks so owners know when today’s numbers are really “today’s” and can spot delayed loads before they hit dashboards or models.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Completeness and volume&lt;/strong&gt;: Are we seeing all the records we expect? Volume and completeness tests compare current loads against historical baselines or source systems to flag partial backfills, silent drops, or suspicious spikes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Structural stability (schema)&lt;/strong&gt;: Did the shape of the data change? Schema checks detect added/removed columns and type changes so that teams don’t discover breaking changes only after pipelines or reports start failing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Semantic validity (values and business rules)&lt;/strong&gt;: Do the values themselves make sense? Column-level and X-Validation tests enforce constraints like uniqueness, ranges, patterns, cross-table reconciliations, and more advanced anomaly detection on row counts and null percentages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wrapped around these pillars is the operational layer:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Genchi runs these checks continuously (on schedule or on pipeline completion), emits real-time alerts, and helps teams drill into failing records and trends instead of debugging blind.&lt;/li&gt;
  &lt;li&gt;Its health signals flow into the catalog and incident tooling, so consumers see quality status alongside metadata and contract breaches are handled consistently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a mesh where domains publish data products with explicit, machine-enforced quality guarantees, and consumers can safely reuse them without a central team hand-holding every request.&lt;/p&gt;

&lt;h3 id=&quot;genchis-system-architecture&quot;&gt;Genchi’s system architecture&lt;/h3&gt;

&lt;p&gt;The Genchi system is designed to handle validation workflows triggered by user actions or automated schedules. It utilizes Temporal for reliable workflow orchestration and Kafka for event-driven data distribution to downstream consumers.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-5.png&quot; alt=&quot;&quot; style=&quot;width:80%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 5. Genchi’s system architecture.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h3 id=&quot;triggering-tests-on-pipeline-completion-with-sync-with-pipeline-swp&quot;&gt;Triggering tests on pipeline completion with Sync with Pipeline (SWP)&lt;/h3&gt;

&lt;p&gt;Before SWP, Genchi tests lived on their own cron schedules, completely decoupled from the Airflow pipelines that actually produced the data. Teams had to manually copy pipeline crons into Genchi, juggle offset/lookback math to point at the “right” pipeline batch, and hope that nothing drifted over time. The result: misconfigured pipeline duration Service Level Agreement (SLA) checks, and tests sometimes running too early, too late, or against the wrong batch. This leads to noisy alerts and false-positive Data Production Issue (DPI).&lt;/p&gt;

&lt;p&gt;To solve this, Genchi leans on &lt;em&gt;Lighthouse&lt;/em&gt;, Grab’s pipeline execution and monitoring service. Lighthouse tracks when Airflow jobs start, finish, and which data interval they cover, and exposes that as structured execution events that the rest of the observability stack can consume.&lt;/p&gt;

&lt;p&gt;SWP then flips Genchi from “best-effort cron alignment” to event-driven orchestration. Instead of guessing when a pipeline should have finished, Genchi listens to Lighthouse execution events. When a pipeline run is completed, Lighthouse emits an event with the run’s schedule and data interval; Genchi consumes that event, spins up an ad-hoc validation run aligned to that execution, and runs data-quality tests on the corresponding slice of data.&lt;/p&gt;

&lt;p&gt;Pipeline-freshness is modeled as its own run type, separate from the data-quality tests that run on pipeline completion. Instead of inspecting rows, it is triggered asynchronously on the same schedule as the pipeline and tracks when each run actually completes in Lighthouse. This gives data producers an intuitive way to get alerted when a pipeline exceeds its expected runtime, and to review historical runtime behavior for any drift over time.&lt;/p&gt;

&lt;p&gt;In practice, this makes test orchestration both simpler and more trustworthy. Users no longer need to think about crons or offsets for their data validation jobs. “Run after my pipeline finishes” becomes the default, with advanced overrides for custom schedules when needed. Misconfigured freshness tests and noisy DPIs drop, because Genchi is now anchored to real pipeline execution signals rather than approximations.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-6.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 6. Genchi pipeline-freshness run page for a table, showing the historical runtime patterns and the SLA status.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;h2 id=&quot;data-contract-registry-the-producerconsumer-agreement-layer&quot;&gt;Data Contract Registry: The producer–consumer agreement layer&lt;/h2&gt;

&lt;p&gt;At Grab, a data contract is the explicit, versioned agreement between a producer and its consumers that defines the data’s shape and semantics, the quality and availability guarantees around it, and the rules for how and for how long it may be used. The Data Contract Registry is the source of truth for these agreements, while Genchi and platform-specific observability stacks continuously verify that reality still matches what the contract promises.&lt;/p&gt;

&lt;h3 id=&quot;what-data-contract-registry-does-for-data-mesh&quot;&gt;What Data Contract Registry does for data mesh&lt;/h3&gt;

&lt;p&gt;Within Grab’s data mesh, the Data Contract Registry is the producer–consumer agreement layer: it centralizes contracts for key assets (data lake tables, Kafka streams, metrics) so expectations on shape, quality, SLAs, and lifecycle live in one canonical place instead of being scattered across individual platforms. That single source of truth underpins Hubble certification (only assets with valid contracts can be certified), gives Kinabalu (Grab’s central incident lifecycle orchestrator) the context it needs to open and route DPIs when checks fail, and lets Ouroboros (a table lifecycle management tool) interpret lifecycle clauses consistently.&lt;/p&gt;

&lt;p&gt;This is important for a data mesh because it transforms the concept of “data as a product” from a mere slogan into an operational reality. Contracts provide domain teams with a clear and enforceable method to specify their guarantees, offering consumers a solid foundation for trust and data reuse across domains. Importantly, a contract is only valuable if its promises are verifiable and enforceable. Schema expectations, quality checks, and SLAs are all connected to concrete tests and health endpoints, enabling downstream platforms to automatically detect breaches and manage or mitigate breaking changes, rather than treating the contract as static documentation.&lt;/p&gt;

&lt;p&gt;On top of storing contracts, the registry also manages contract changes. When a contract evolves, say a schema tweak, a new freshness SLA, or a planned deprecation, it identifies the right stakeholders (direct downstream owners, heavy query users, and, for critical assets, deeper dependencies) and pushes targeted Slack notifications. Producers get a structured way to roll out changes safely while consumers get timely, actionable signals instead of surprise breakages so that both enforcement and change management are baked into the mesh.&lt;/p&gt;

&lt;p&gt;In addition to storing contracts, the registry also manages contract changes. When a contract evolves such as a schema adjustment, a new freshness SLA, or a planned deprecation, it identifies the appropriate stakeholder (direct downstream owners, heavy query users, and critical assets with deeper dependencies) and then sends targeted Slack notifications to these stakeholders. This process provides producers with a structured method to implement changes safely, while consumers receive timely and actionable alerts, preventing unexpected disruptions. As a result, both enforcement and change management are seamlessly integrated into the data mesh.&lt;/p&gt;

&lt;h3 id=&quot;the-data-contract-specification&quot;&gt;The data contract specification&lt;/h3&gt;

&lt;p&gt;Under the hood, a data contract in the registry is a JSON construct that follows the contract specification. Grab’s data contract specification is inspired by the public &lt;a href=&quot;https://datacontract-specification.com/&quot;&gt;Data Contract Specification&lt;/a&gt;, but adapted to our environment so that contracts plug directly into our observability stack and automated incident management workflows.&lt;/p&gt;

&lt;p&gt;Notably, we embed data health and test health URLs in the contract itself. Each data-quality rule points to a concrete health endpoint, so Kinabalu can determine contract breaches and create DPIs by calling those test health URLs, without hard-coding what “healthy” means. For example, a completeness test on the latest partition can be marked healthy only if the last &lt;em&gt;N&lt;/em&gt; days are complete, not just because the most recent test run happened to pass. The data health URL at the contract root then lets Kinabalu fetch the overall diagnosis and decide who the DPI should be assigned to. More on this will be covered in Part III.&lt;/p&gt;

&lt;p&gt;Here’s a simplified example of a contract for a data-lake table:&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;specification_version&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;mi&quot;&gt;1&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;asset_urn&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;urn:li:dataset:(urn:li:dataPlatform:hive,genchi.validation_jobs,PROD)&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;entity_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;datalake_table&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;health_url&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;https://example-hugo.grab.com/assets/genchi.validation_jobs/health&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;contract_details&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;nl&quot;&gt;&quot;contact&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;nl&quot;&gt;&quot;oncall_group&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;oncall-genchi&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;slack_channel&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;ask-genchi&quot;&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;nl&quot;&gt;&quot;terms&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;nl&quot;&gt;&quot;usage&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;Source of truth for all genchi validation jobs.&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;limitations&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;Not suitable for real-time use cases.&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;notice_period_in_days&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;mi&quot;&gt;14&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;nl&quot;&gt;&quot;schema&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;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;genchi&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;health_url&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;https://example-genchi.grab.com/assets/genchi.validation_jobs/tests/fundamental_schema_test/health&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;uid&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;fundamental_schema_test&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;version&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;mi&quot;&gt;1&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;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;sla&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;nl&quot;&gt;&quot;freshness&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;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;genchi&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;health_url&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;https://example-genchi.grab.com/assets/genchi.validation_jobs/tests/fundamental_freshness_test/health&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;uid&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;fundamental_freshness_test&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;version&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;mi&quot;&gt;1&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;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;lifecycle&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;kc&quot;&gt;null&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;nl&quot;&gt;&quot;data_quality&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;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;genchi&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;health_url&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;https://example-genchi.grab.com/assets/genchi.validation_jobs/tests/fundamental_completeness_test/health&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;uid&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;fundamental_completeness_test&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;version&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;mi&quot;&gt;1&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;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;w&quot;&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The contract in the registry only stores references to enforceable rules, which are an identifier and version, like the example above, rather than the full configuration body. This keeps contracts lightweight and tool-agnostic, while giving rule-enforcement tools (Genchi for data-quality tests, Ouroboros for table lifecycle) and Kinabalu a stable handle to resolve the actual rule definition in their own systems, without duplicating configuration or letting it drift across platforms.&lt;/p&gt;

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

&lt;p&gt;By bringing Hubble, Genchi, and the Data Contract Registry together, we provide the foundational tools to build trust in certified data assets. Hubble enables discovery and establishes domains and ownership; the Data Contract Registry captures explicit expectations between producers and consumers; and Genchi continuously validates those promises with tests on freshness, volume, schema, and business rules. Hubble’s certification engine then evaluates these ownership, contract, and quality signals to decide whether an asset meets Grab’s standards and surfaces that as a visible certification state. As a result, consumers can confidently default to certified assets, usage converges on a smaller, better-governed pool of datasets, and certification becomes a mechanism that changes producer behavior and guides consumer choice. We saw this convergence in practice. In just one year since the Signals Marketplace campaign began in 2024, the number of P80 datasets (the most used tables that account for 80% of all queries) has dropped by over 58%.&lt;/p&gt;

&lt;p&gt;This data foundation is especially important in an AI-first future for Grab. Certified streams, tables, metrics, and attributes give AI agents and automated analytics a default substrate they can rely on. With Hubble and Genchi, data producers have clear ownership, contracts, and observability. Data consumers can discover and trust certified assets without guesswork, and platform teams can measure and improve Signals Marketplace health over time (for example, queries on certified assets, lineage depth, and cost). Together, these capabilities turn “data mesh” from a slogan into an operational, AI-ready marketplace of reliable, reusable signals that power decisions across Grab.&lt;/p&gt;

&lt;div class=&quot;post-image-section&quot;&gt;&lt;figure&gt;
  &lt;img src=&quot;/img/datamesh-part-two/figure-7.png&quot; alt=&quot;&quot; style=&quot;width:90%&quot; /&gt;&lt;figcaption align=&quot;middle&quot;&gt;Figure 7. Building trust in certified data assets through discovery, contracts, and continuous validation.&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

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

&lt;p&gt;In the next blog, we’ll zoom into the DPI process itself with Kinabalu as the incident lifecycle orchestrator:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How Genchi test failures and data contract breaches turn into DPIs.&lt;/li&gt;
  &lt;li&gt;How DPI is assigned based on root causes and what sets the priority level.&lt;/li&gt;
  &lt;li&gt;The patterns we’ve seen in “noisy” vs actionable DPIs, and what we’ve changed in our platforms.&lt;/li&gt;
  &lt;li&gt;How we’re using automation and agents to reduce DPI toil and close the loop back into certification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We’ll walk through concrete case studies showing how a single broken table moves from first failure, to diagnosis and fix, to updated contracts and a more resilient certified data asset.&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://grb.to/gebdatamesh2&quot;&gt;join our team today&lt;/a&gt;!&lt;/p&gt;
</description>
        <pubDate>Thu, 30 Apr 2026 00:23:00 +0000</pubDate>
        <link>https://engineering.grab.com/data-mesh-part-2-the-foundational-tools-behind-certification</link>
        <guid isPermaLink="true">https://engineering.grab.com/data-mesh-part-2-the-foundational-tools-behind-certification</guid>
        
        <category>Data</category>
        
        <category>Database</category>
        
        <category>Engineering</category>
        
        
        <category>Engineering</category>
        
      </item>
    
  </channel>
</rss>
