18 Jul 2025

Slicing time at scale: Building a Scala SDK for petabyte CDC on GCP at scale

You know that cloud bill that makes your CFO cry?

Ours hit $100K/month because Spark was reading entire petabyte tables daily. “Just read everything” they said. “Spark is fast” they said.

Reality:

  • Full table scans on 5TB partitions
  • Jobs timing out after 6 hours
  • OOM errors killing executors
  • $100K monthly BigQuery + GCS costs

Then we built a Scala SDK for partition-aware CDC (change data capture).

The result:

  • $75K monthly savings (75% cost reduction)
  • 92% scan reduction (read only changed data)
  • Jobs finish in minutes (not hours)
  • Zero OOM errors (partition pruning works)

This post shows you the complete architecture - getAffectedPartitions, CDC without ACID, cloud-native backups, and production lessons from petabyte lakes.

Battle-tested code from GCP production.


Why this matters

Here’s the petabyte-scale reality:

Before (naïve approach):

  • Read entire tables every run
  • “Spark is fast, it can handle it”
  • Full table scans daily
  • Pray jobs finish

The cost:

  • $100K/month cloud bills
  • 6-hour job runtimes
  • Executor OOMs and timeouts
  • 95% of scanned data unchanged
  • Team losing faith

What we needed:

  • Scan only changed partitions
  • CDC without ACID transactions
  • Partition-aware backups
  • Cost control
  • Observability

This is the SDK that solved it.


The Madness of Scale: Why “Just Read Everything” Is Dead

Imagine managing a multi-petabyte data lake spread across regions, powered by Delta and Hive tables. Teams are deploying DAGs daily, some buggy, some brilliant, but all hungry for data freshness.

The naïve approach? Read everything every time.
Reality? Your cloud bill explodes, jobs time out, and devs lose faith.

The Real-World Pain: Symptoms of Scale Gone Wrong

At petabyte scale, naïve CDC logic quickly reveals itself as a ticking time bomb. Here’s what we saw in the trenches:

  • Exploding Cloud Bills: Running full table scans daily on multi-terabyte datasets meant tens of thousands of dollars in GCS egress and BigQuery query costs. The cost curve was exponential, not linear.

  • Job Failures & Timeouts: Spark jobs reading entire partitions would fail due to executor OOMs or cluster preemption. DAGs timed out regularly, causing cascading failures downstream.

  • Inefficient Resource Usage: Cluster resources were wasted scanning unchanged data, leaving little room for real transformations or analytics workloads.

  • Developer Frustration: Teams spent more time debugging flaky pipelines than delivering features. Onboarding new engineers became a nightmare due to complex, brittle codebases.

Common Anti-patterns in Naïve CDC Logic

  • Full Table or Partition Scans: No filtering, just brute force reading of entire datasets every run.

  • Relying on Timestamps in Data: Using ingestion timestamps as change indicators is brittle and often inaccurate.

  • Overusing MERGE Statements: Blindly merging entire tables without partition pruning leads to massive shuffle and compute costs.

  • Ignoring Cloud Storage Semantics: Not accounting for eventual consistency or file listing delays causing inconsistent snapshots.

  • Hardcoding Paths and Schemas: Making pipelines fragile and hard to maintain.

What We Needed: Discipline Encoded as Code

We realized that at scale, data engineering is a software engineering challenge first and foremost. We needed an SDK that:

  • Understands Scale: Knows how to avoid scanning everything.

  • Whispers to Partitions: Detects changes precisely.

  • Is Cloud-Native: Leverages GCP features without locking in.

  • Is Modular and Testable: So teams can trust and extend it.

This was the genesis of our cloud-native, Scala-based SDK.


SDK Philosophy: Beyond Utilities, Into a Modular Toolkit

Most SDKs are glorified helper libraries. Ours is different.

We designed it to be:

  • Modular: Independent components that compose cleanly
  • Composable: DSL-style chaining that reads like a story
  • Cloud-native: Abstracted from vendor specifics but optimized for GCP
  • Observability-first: Audit records baked in, not bolted on

Architectural Decisions: Why Modularity?

Modularity was key to managing complexity and enabling reuse. By breaking down the SDK into small, focused traits and classes, we could:

  • Easily Swap Implementations: For example, swapping GCS with S3 or Azure Blob Storage without rewriting logic.

  • Improve Testability: Smaller components are easier to unit test and mock.

  • Enable Parallel Development: Different teams could own different modules.

  • Simplify Maintenance: Bugs and enhancements are isolated.

Trait-Based Abstractions and Avoiding Implicits

We chose explicit trait-based abstractions over Scala implicits to:

  • Improve Readability: Explicit dependencies are easier to track.

  • Avoid Magic: Reducing cognitive overhead for new contributors.

  • Enable Better Compile-Time Checks: Traits enforce contracts clearly.

SDK Lifecycle: Publishing and Versioning

Our SDK follows a strict lifecycle:

  • Semantic Versioning: Major versions introduce breaking changes, minor versions add features, patches fix bugs.

  • Automated Publishing: CI/CD pipelines publish artifacts to Maven Central on successful builds.

  • Backward Compatibility: We maintain compatibility for at least two major versions to avoid breaking users.

  • Documentation & Changelogs: Every release is accompanied by detailed docs and migration guides.

Testability and Continuous Integration

  • Unit Tests: Cover each module in isolation.

  • Integration Tests: Run on ephemeral GCP resources to validate end-to-end flows.

  • Performance Benchmarks: Monitor SDK performance regressions.

  • Static Analysis: Enforce code quality and style.

This philosophy ensures the SDK is robust, maintainable, and scalable in both code and usage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
+-------------------------+
|     CloudStorage        |
+-------------------------+
             |
             v
+-------------------------+
|  AffectedPartitions     |
+-------------------------+
             |
             v
+-------------------------+
|      CDC Merge SCD1     |
+-------------------------+
             |
             v
+-------------------------+
|      Backup & Audit     |
+-------------------------+

Partition Whispering: getAffectedPartitions

The million-dollar question:

“Which partitions actually changed?”

Without ACID or full snapshots, we can’t just “diff tables.” We whisper to partitions by comparing snapshots of file metadata.

1
2
3
4
val oldSnapshot = cloudStorage.listFiles(checkpointPath)
val newSnapshot = cloudStorage.listFiles(currentPath)
val deltaFiles = diff(oldSnapshot, newSnapshot)
val affectedPartitions = extractPartitions(deltaFiles)

Metadata Snapshotting: Capturing the State of the Lake

Our snapshotting involves capturing file metadata such as:

  • File path
  • Size
  • Last modified timestamp
  • Partition keys parsed from path

We store snapshots as compact manifests (e.g., Parquet or JSON files) to avoid expensive repeated listings.

Cloud-Specific Quirks: GCS Eventual Consistency and More

  • Eventual Consistency: GCS listings may lag behind writes for seconds to minutes. We mitigate this by:

    • Using versioned manifests to snapshot state at a point in time.

    • Adding retries and backoff in listings.

  • Listing Limits: GCS limits the number of files returned per request. We paginate and parallelize listings.

  • IAM Permissions: Fine-grained IAM roles are necessary to list and read files efficiently.

Partition Extraction Strategies

Extracting partitions from file paths is non-trivial:

  • Hierarchical Paths: e.g., gs://bucket/events/dt=2025-07-18/region=us/file.parquet

  • Dynamic Partition Keys: Some datasets have varying partition keys.

  • Schema Evolution: New partitions or keys may appear over time.

We use configurable regex-based parsers and fallback heuristics to reliably extract partitions.

Flow Diagram:

1
2
3
4
5
6
+-------------+      +-------------+      +-----------------+      +-----------------+
| prevFiles[] | ---> | currFiles[] | ---> | changedFiles[]  | ---> | affectedParts[] |
+-------------+      +-------------+      +-----------------+      +-----------------+
        |                  |                    |                        |
        |                  |                    |                        |
   (snapshot)          (snapshot)          (diff by path & ts)      (extract partition keys)

Our CloudStorage trait abstracts GCS, S3, and Azure, making this logic cloud-agnostic yet performant.


CDC Without ACID: performDelta

No UPDATEs. No MERGE statements on raw Parquet.

We fake CDC (SCD Type 1) by joining base and incoming datasets, applying “newer wins” logic, and overwriting partitions atomically.

1
2
3
4
5
6
7
8
9
val output = base
  .join(newData, Seq("id"), "outer")
  .map {
    case (Some(old), Some( new) ) => newerWins(old, new)
    case (None, Some(new))
    => new
    case (Some(old), None)
    => old
  }

Deep Dive: Join Strategy and Data Consistency

  • Outer Join: Ensures all records from both base and new datasets are considered.

  • Newer Wins: Based on timestamps or version columns, we pick the latest record.

  • Handling Deletes: We treat absence in new data as retention of old data; explicit deletes require separate logic.

Handling Schema Evolution

  • Schema Merging: We allow new columns in incoming data and merge schemas dynamically.

  • Type Compatibility: We apply type promotion where safe (e.g., int → long).

  • Validation: Fail fast if incompatible schema changes are detected.

Deduplication Techniques

  • Within Incoming Data: We deduplicate incoming records by primary key and timestamp.

  • Across Batches: Our partition-level CDC ensures no overlapping writes.

  • Using Watermarks: For streaming data, we use event-time watermarks to delay processing and handle late data.

Recovery in Case of Partial Failure

  • Atomic Writes: We write to temporary locations and atomically swap partitions.

  • Backup Before Overwrite: See next section.

  • Idempotency: CDC operations are designed to be idempotent, allowing safe retries.

  • Audit Records: Capture operation status and errors for troubleshooting.


Backup Strategy Abstraction: Because Mistakes Happen

Before overwriting partitions, we back up existing data. But backup strategies vary:

  • Full snapshot copies
  • Incremental backups
  • Cloud-native versioning (GCS object versioning)

Our SDK exposes a BackupStrategy trait:

1
2
3
trait BackupStrategy {
  def backup(partitionPath: String): Unit
}

Trade-offs Between Snapshot and Versioning-Based Backups

Backup TypeProsCons
Full Snapshot CopySimple, easy to restoreHigh storage cost, slow
Incremental BackupEfficient storage, fasterComplex restore logic
Cloud VersioningNo extra storage, automaticLimited to supported clouds, cost depends on versioning policy

Cost and Performance Considerations Across Clouds

  • GCS: Versioning is cheap and native, but listing versions can be slow.

  • S3: Supports versioning with lifecycle policies.

  • Azure Blob: Snapshots are supported but have different semantics.

Backup frequency and retention policies are tuned to balance cost and recovery SLAs.

Restore Strategies

  • Point-in-Time Restore: Revert partitions to a known good snapshot.

  • Selective Restore: Restore only corrupted or failed partitions.

  • Automated Rollbacks: Triggered by audit failures or alerts.


AuditRecord Pattern: Observability by Design

Every CDC operation emits an AuditRecord capturing:

  • Timestamps
  • Partition keys
  • Number of records processed
  • Errors and retries

This pattern lives in the SDK core, enabling:

  • Monitoring dashboards
  • Automated alerts
  • Post-mortem analysis

Example snippet:

1
2
3
4
5
6
7
8
9
case class AuditRecord(
                        jobId: String,
                        partition: Map[String, String],
                        processedCount: Long,
                        timestamp: Instant,
                        status: String,
                        errorMessage: Option[String] = None,
                        retries: Int = 0
                      )

Schema for Observability Events

Our audit schema includes:

FieldTypeDescription
jobIdStringUnique job identifier
partitionMap[String, String]Partition keys and values
processedCountLongNumber of records processed
timestampInstantOperation timestamp
statusStringSuccess, Failed, Retried
errorMessageOption[String]Error details if any
retriesIntNumber of retry attempts

Wiring into Pub/Sub and Stackdriver

  • AuditRecords are serialized as JSON and published to a dedicated Pub/Sub topic.

  • Stackdriver (Cloud Logging) sinks subscribe to the topic for real-time log ingestion.

  • Alerts are configured on error rates and latency anomalies.

Real Dashboards Built with This Data

  • CDC Health Dashboard: Success/failure rates per partition and job.

  • Latency Monitoring: Time between data arrival and CDC completion.

  • Cost Attribution: Correlate audit events with resource usage.

  • On-call Alerts: PagerDuty integration for critical failures.


Modular API Composition with DSL

Our SDK shines in its expressive DSL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
AffectedPartitions
  .on("gs://bucket/events")
  .since("2025-07-17")
  .identify()

CdcType1
  .on("gs://incoming")
  .merge()
  .into("bigquery.customers")
  .withBackup(backupStrategy)
  .audit()

Real DSL Flows: Batch Mode Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
val partitions = AffectedPartitions
  .on("gs://my-bucket/events")
  .since("2025-07-01")
  .identify()

CdcType1
  .on("gs://my-bucket/incoming")
  .merge()
  .into("bigquery.my_dataset.customers")
  .withBackup(snapshotBackup)
  .audit()
  .run()

Streaming Mode Example

1
2
3
4
5
6
7
8
StreamingCdcType1
  .source("gs://streaming-bucket/events")
  .withWatermark("event_time", "10 minutes")
  .merge()
  .into("bigquery.streaming.customers")
  .withBackup(versioningBackup)
  .audit()
  .start()
  • Supports continuous processing with checkpointing.

  • Handles late data and retries transparently.

Benefits of This DSL

  • Declarative: Users focus on what instead of how.

  • Composable: Each step returns a builder for chaining.

  • Extensible: New modules plug in without breaking existing flows.


Real-World Deployment Examples

Our SDK powers:

  • A multi-region event lake with daily partition detection
  • CDC pipelines merging streaming and batch data
  • Backup and audit layers integrated with GCP Scheduler and Pub/Sub
+------------+   +-------------+   +----------------+   +------------+
| GCS Source | → | Dataproc Job| → | SDK CDC Logic  | → | BigQuery   |
+------------+   +-------------+   +----------------+   +------------+

Case Study 1: Multi-Region Event Lake with Partition Pruning

Background

A global e-commerce company ingests terabytes of event data daily from multiple regions. Each region writes to its own GCS bucket partitioned by date and region.

Challenge

  • Running full scans over all regions daily was unsustainable.

  • Data freshness SLA was tight (under 30 minutes).

Solution

  • Used AffectedPartitions to detect changed partitions per region.

  • Scheduled Dataproc jobs to process only affected partitions.

  • CDC logic merged event data into BigQuery partitioned tables.

  • Backup and audit layers ensured recoverability and observability.

Results

  • Reduced data scanned by 95%.

  • Cut daily processing time from 3 hours to 15 minutes.

  • Cloud costs dropped by 80%.

  • Improved developer confidence with clear audit trails.

Case Study 2: CDC + Backup in E-commerce Pipeline

Background

An online retailer processes customer and order data with frequent updates and corrections.

Challenge

  • Need SCD Type 1 CDC without transactional support.

  • Backup required to protect against accidental overwrites.

  • Schema evolution was frequent due to product catalog changes.

Solution

  • Leveraged CdcType1 with deduplication and newer-wins logic.

  • Implemented BackupStrategy with incremental backups on GCS.

  • Integrated audit events into Pub/Sub for monitoring.

  • Used schema merging and validation to handle evolution.

Results

  • Zero data loss incidents after deployment.

  • Faster recovery from pipeline errors.

  • Clear visibility into data freshness and processing status.


Mocked Use Case + Example Output

Scenario: Incoming data for dt=2025-07-18 / region=us adds new files.

Affected partitions:

Partition KeyValue
dt2025-07-18
regionus

CDC merge result:

IDBase NameIncoming NameFinal Name
101JohnJonnyJonny
102Alice-Alice
103-BobBob

File-Level Diff Logic

1
2
3
4
5
6
7
8
def diff(oldFiles: List[FileMeta], newFiles: List[FileMeta]): List[FileMeta] = {
  val oldSet = oldFiles.map(f => (f.path, f.lastModified)).toSet
  val newSet = newFiles.map(f => (f.path, f.lastModified)).toSet
  val changed = newSet.diff(oldSet).map { case (path, _) =>
    newFiles.find(_.path == path).get
  }
  changed.toList
}
  • Compares file paths and modification timestamps.

  • Identifies new or updated files.

  • Efficient with parallelized metadata listing.

Performance Tuning

  • Parallelize file listings with thread pools.

  • Cache snapshots in memory and storage.

  • Use bloom filters for large file sets.

  • Batch partition extraction to reduce overhead.


Platform Metrics Saved

MetricBeforeAfter
Runtime18 min2.3 min
Files scanned~4,000~120
BigQuery Costs$$$$
Developer Onboarding Time3 weeks3 days
Code Reuse20%90%

Developer Feedback and Onboarding Benefits

  • New engineers ramped up faster due to clear abstractions.

  • Reduced cognitive load by hiding complex cloud interactions.

  • Improved collaboration with shared SDK ownership.

SRE Simplifications

  • Reduced alert noise with better failure isolation.

  • Faster incident resolution with audit logs.

  • Predictable resource usage and cost forecasting.


What’s Next?

  • SCD Type 2: Adding history tracking and versioning for auditability and compliance.

  • Open Source Drop: Sharing the SDK with the community under Apache 2.0 license.

  • CLI Interface: For non-Scala users and easier adoption in ad-hoc workflows.

  • GDE Application: Sharing knowledge and growing the ecosystem with Google Developer Experts.

  • SDK Versioning Enhancements: Semantic versioning enforcement and automated migration tooling.

  • Open-Source Compliance Setup: License scanning, contributor guidelines, and community governance.



This is the blog I wish I had read when starting out.
Not just an intro, but a blueprint for building scalable, maintainable, cloud-native data engineering workflows.


Note
Want to dive deeper? The SDK abstractions, design docs, and example code snippets live in our GitHub repo .

classDiagram
  note "Low-level SDK Architecture"
  class Partition {
    +key: String
    +value: String
  }
  class CloudStorage {
    +listFiles(path: String): List[FileMeta]
  }
  class AffectedPartitions {
    +identify(): List[Partition]
  }
  class CdcType1 {
    +merge(): Unit
  }

graph TD A[CloudStorage] --> B[AffectedPartitions] B --> C[CdcType1 Merge] C --> D[Backup & Audit]

SDK Internals & GCP Interop

Dataproc Tips

  • Executor Memory Tuning: Optimize executor heap and off-heap memory to prevent OOMs during large joins.

  • Shuffle Behavior for Large Joins: Use shuffle partitions tuning and adaptive query execution to balance load.

  • GCS Connector Optimization: Enable caching and configure retry policies to mitigate GCS eventual consistency.

BigQuery Integration

  • Partition Pruning: Ensure merge queries include partition filters to reduce scanned data.

  • Merge Statement Caveats: Avoid merging very large tables without partition filters; monitor query costs closely.

GCS Snapshot Performance

  • Metadata Listing Limits: Use parallel listing and manifest caching to overcome GCS listing throttling.

  • IAM Tricks to Reduce Latency: Use least-privilege roles and service accounts with minimal permissions for faster metadata access.

Pub/Sub as Orchestration

  • CDC + Notification Wiring: Publish partition change events to Pub/Sub to trigger downstream jobs.

  • Audit Events to Pipeline Triggers: Use audit logs in Pub/Sub to automate alerts, retries, and SLA monitoring.


TL;DR

  • The problem: $100K+ monthly cloud bills from full table scans, 8-hour CDC jobs, 10TB scans for 200GB of actual changes, petabyte-scale Hive tables with no ACID
  • The root cause: CDC workflows scanned entire tables instead of only changed partitions, no partition-aware tooling existed
  • The solution: Built Scala SDK with getAffectedPartitions API for partition-aware CDC on GCP (BigQuery + GCS + Hive)
  • Key implementation: Hive partition metadata + GCS snapshots + timestamp comparison = identify only changed partitions
  • Production results: $75K monthly savings, 92% scan reduction, 8-hour jobs → 45 minutes, 10TB → 800GB scans
  • SDK features: PartitionList (immutable partition sets), CdcType1 (merge engine), backup/restore, GCS interop
  • Architecture patterns: CloudStorage abstraction, snapshot-based partition detection, BigQuery MERGE with partition filters
  • CDC workflow: Snapshot GCS → compare timestamps → identify affected partitions → generate partition filters → merge only changed data
  • GCP integration: Dataproc for Spark execution, BigQuery for MERGE statements, GCS for data storage, Pub/Sub for orchestration
  • Code example: getAffectedPartitions(baseSnapshot, currentSnapshot, List("year", "month", "day")) returns only changed partition keys
  • Type-1 merge: CdcType1.merge(source, target, partitions, mergeKey = "id") updates existing records, inserts new ones
  • Backup strategy: Pre-merge snapshots with metadata, partition-level restore capability, audit trail for compliance
  • Dataproc tuning: Executor memory optimization, shuffle partition tuning, GCS connector caching, adaptive query execution
  • BigQuery optimization: Partition pruning in MERGE statements, query cost monitoring, partition filter validation
  • Performance tricks: Parallel GCS listing, manifest caching, IAM optimization, metadata listing limits handling
  • Lessons learned: Partition key design matters, always include date partitions, metadata caching is critical, monitor GCS throttling
  • Common pitfalls: Avoid merging without partition filters, don’t ignore GCS eventual consistency, tune executor memory for large joins
  • Bottom line: Partition-aware CDC cuts costs by 92% - stop scanning entire tables when you only need changed partitions
Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "The Zen of Debugging: When println() is Your Only Friend"