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.
| |
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.
| |
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.parquetDynamic 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:
| |
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.
| |
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:
| |
Trade-offs Between Snapshot and Versioning-Based Backups
| Backup Type | Pros | Cons |
|---|---|---|
| Full Snapshot Copy | Simple, easy to restore | High storage cost, slow |
| Incremental Backup | Efficient storage, faster | Complex restore logic |
| Cloud Versioning | No extra storage, automatic | Limited 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:
| |
Schema for Observability Events
Our audit schema includes:
| Field | Type | Description |
|---|---|---|
| jobId | String | Unique job identifier |
| partition | Map[String, String] | Partition keys and values |
| processedCount | Long | Number of records processed |
| timestamp | Instant | Operation timestamp |
| status | String | Success, Failed, Retried |
| errorMessage | Option[String] | Error details if any |
| retries | Int | Number 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:
| |
Real DSL Flows: Batch Mode Example
| |
Streaming Mode Example
| |
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
AffectedPartitionsto 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
CdcType1with deduplication and newer-wins logic.Implemented
BackupStrategywith 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 Key | Value |
|---|---|
| dt | 2025-07-18 |
| region | us |
CDC merge result:
| ID | Base Name | Incoming Name | Final Name |
|---|---|---|---|
| 101 | John | Jonny | Jonny |
| 102 | Alice | - | Alice |
| 103 | - | Bob | Bob |
File-Level Diff Logic
| |
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
| Metric | Before | After |
|---|---|---|
| Runtime | 18 min | 2.3 min |
| Files scanned | ~4,000 | ~120 |
| BigQuery Costs | $$$ | $ |
| Developer Onboarding Time | 3 weeks | 3 days |
| Code Reuse | 20% | 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.
Final Links & Wrap-Up
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.
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
}
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
getAffectedPartitionsAPI 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
