Software Engineering, Data Engineering, Functional Programming, Scala, GNU/Linux, Data, AI/ML, and other things
28Jun 2025
Functional utilities for data engineering: stop repeats in Scala pipelines now
You’re writing a Spark pipeline on GCP. Again. You need to:
Handle nulls and parse dates
Wrap risky operations in try-catch
Find affected partitions since last run (which strategy? GCS multi-threaded? DataFrame-based? Bucket-level?)
Work with GCS blobs using clean syntax (blob"gs://bucket/file")
Vacuum your Delta tables, handle schema evolution, run data quality checks
You copy-paste from last week’s code. The pipeline works, but you know this pattern will show up ten more times this quarter.
“Can I just… not repeat myself? Like, ever again?”
I had this exact frustration after writing my 50th affected partitions check and manually running OPTIMIZE commands on every Delta table. So I built a comprehensive library of functional utilities that turned repetitive pipeline patterns into composable, type-safe operations.
This post shows you how to build these utilities from scratch - not just what they do, but WHY they exist, HOW they work internally, and WHEN to use them. We’ll cover:
Higher-order functions that wrap unsafe operations
Implicit classes that extend types without inheritance
Delta Lake utilities (vacuum, compaction, Z-ordering)
Schema evolution strategies (partitioned vs non-partitioned)
Data quality wrappers that integrate DQ frameworks
Schema comparison utilities for detecting changes
Production backup strategies that work across Spark
All code runs on Scala 2.13 + Spark 3.x + Delta Lake + GCP. You can grab the working implementation from dataengineering-savvy on GitHub
.
Information
The code in this post is battle-tested in production data pipelines processing TBs daily. Each utility solved a real pain point.
Why functional utilities matter
Here’s the thing: data engineering isn’t just about moving data. It’s about doing it safely, repeatedly, and without surprises.
When you’re building pipelines:
Operations fail (null pointers, network timeouts, bad data)
Dates come in 15 different formats
You need CDC logic that works the same way every time
Delta tables need maintenance (vacuum, compaction, Z-ordering)
Schemas evolve - columns get added/dropped
Data quality checks must run consistently
Table backups can’t just be “copy files” - partitions matter
Type conversions should be safe, not crashy
Writing this logic inline every time leads to:
Inconsistent error handling - Some devs throw, some return Option, some log and swallow
Copy-paste bugs - That date parser you copied? It has a timezone bug
Unreadable code - LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")) vs yearMonthDate"2025-06-28"
No reuse - The CDC logic you wrote for Customer? Can’t reuse it for Orders without modification
Manual ops - Running OPTIMIZE and VACUUM on every Delta table by hand
Functional utilities fix this by:
Wrapping common patterns in higher-order functions (functions that take functions as arguments)
Using Scala’s type system to prevent runtime errors at compile time
Making code read like English through well-designed APIs
Enforcing consistency - one way to do CDC, one way to handle errors, one way to parse dates, one way to optimize Delta tables
Automating repetitive ops - schema evolution, data quality, table maintenance
The compiler becomes your ally. If it compiles, it handles errors correctly.
Production gotcha
Don’t confuse compile-time type safety with runtime data validation. These utilities prevent structure errors (wrong types, missing methods), not data errors (null IDs, negative amounts, malformed JSON). You still need data quality checks - the utilities just make implementing those checks safer and more consistent.
graph TB
A[Repetitive Pipeline Code] -->|Functional Utilities| B[Composable Abstractions]
B --> C[Higher-Order Functions]
B --> D[Implicit Classes]
B --> E[String Interpolators]
B --> F[Type Safety]
C --> G[Error Handling]
D --> H[Type Extensions]
E --> I[Domain-Specific Syntax]
F --> J[Compile-Time Guarantees]
G --> K[Production Pipelines]
H --> K
I --> K
J --> K
style K fill:#90EE90,stroke:#2d7a2d,color:#000
style A fill:#FFB6C6,stroke:#cc0066,color:#000
What we’ll build
We’re going to build eleven categories of utilities:
Error handling - trySafely: Higher-order wrapper for unsafe operations
Type conversions - Java ↔ Scala, JSON ↔ Case classes
Date/time interpolators - yearMonthDate"2025-06-28" instead of parsing boilerplate
GCS string interpolators - blob"gs://bucket/file" for clean cloud storage access
Spark CDC - Change Data Capture with SCD Type 1 logic
Delta Lake ops - Vacuum, compaction, Z-ordering wrappers
Schema evolution - Automatic column add/drop with validation
Data quality - Implicit wrappers for DQ frameworks
Schema comparison - Detect schema changes between DataFrames
Affected partitions - 5 strategies for finding what changed (DataFrame-based, GCS multi-threaded, bucket-level, file-level, view-level)
Each utility uses a different functional pattern. By the end, you’ll understand:
How implicit classes extend types
How higher-order functions capture behavior
How string interpolators work under the hood
How to design APIs that prevent misuse
How to wrap complex operations in simple interfaces
Let’s start with the foundation: safe error handling.
Part 1 - Higher-order functions: Wrapping unsafe operations
WHY we need this
Every data pipeline has unsafe operations:
1
2
3
4
5
6
7
8
// Division by zero
valresult=totalRevenue/orderCount// Crashes if orderCount = 0
// API calls
valresponse=httpClient.get(url)// Throws if network fails
// File operations
valcontent=Source.fromFile("data.csv").getLines()//ThrowsFileNotFoundException
The naive approach:
1
2
3
4
5
6
7
8
9
try{valresult=riskyOperation()result}catch{caseex:Exception=>logger.error("Something went wrong")ex.printStackTrace()-1// Or null, or Option.empty, or throw again...
}
Problems:
Every developer handles errors differently
Copy-pasted everywhere
Hard to test
Inconsistent fallback strategies
We need one function that handles all try-catch logic, makes error handling explicit, and is fully generic.
HOW it works: Higher-order functions
A higher-order function is a function that:
Takes functions as parameters
Returns functions as results
Or both
Think of it like a template: “Give me an unsafe operation, an error message, and a fallback - I’ll handle the try-catch for you.”
Here’s the signature:
1
2
3
4
5
deftrySafely[T, E<:Throwable, C](unsafeCodeBlock:=>T,// The risky operation
errorMessage:Option[String],// Message to log
exceptionHandlingCodeblock:=>Option[C]// Fallback behavior
):Either[C, T]
What’s happening here:
unsafeCodeBlock: => T - The => means “call by name” (lazy evaluation). We don’t execute this immediately - we pass it AS CODE to the function, and it executes inside the try-catch.
[T, E <: Throwable, C] - Three generic types:
T: Return type of the unsafe operation
E: Exception type (constrained to Throwable hierarchy)
C: Return type of the fallback handler
Either[C, T] - Returns Left(fallback) on failure, Right(result) on success. This forces the caller to handle both cases.
flowchart LR
A[Unsafe Operation] --> B{trySafely}
B -->|Try| C[Execute Code Block]
C -->|Success| D[Right result]
C -->|Failure| E[handleException]
E -->|Has Fallback| F[Left fallback]
E -->|No Fallback| G[Throw Exception]
style D fill:#90EE90,stroke:#2d7a2d,color:#000
style F fill:#FFD700,stroke:#cc8800,color:#000
style G fill:#FFB6C6,stroke:#cc0066,color:#000
deftrySafely[T, E<:Throwable, C](unsafeCodeBlock:=>T,errorMessage:Option[String],exceptionHandlingCodeblock:=>Option[C]=None):Either[C, T]={valmessage:String=errorMessage.getOrElse("ERROR: Operation failed")Try{unsafeCodeBlock// Execute the risky operation
}match{caseSuccess(codeBlockResult)=>Right(codeBlockResult)// Success path
caseFailure(exception:Throwable)=>Left(handleException(exception=exception,errorMessage=message,exceptionHandlingCodeblock=exceptionHandlingCodeblock))}}privatedefhandleException[E<:Throwable, C](exception:E,errorMessage:String,exceptionHandlingCodeblock:=>Option[C]):C={valcodeBlockResult=exceptionHandlingCodeblock.map(c=>c)if(codeBlockResult.isEmpty){logger.error(errorMessage)exception.printStackTrace()throwexception// Re-throw if no fallback
}codeBlockResult.get// Return fallback result
}
Step-by-step:
Wrap the unsafe code in Scala’s Try { ... }
Pattern match on Success/Failure
On success: Return Right(result) - the happy path
On failure: Call handleException which either:
Executes the fallback code and returns Left(fallback)
Logs the error and re-throws if no fallback provided
Usage examples
Example 1: Division with fallback
1
2
3
4
5
6
7
8
9
10
11
12
importcom.vitthalmirji.dataengineering.common.Helpers.trySafelyvalresult=trySafely(unsafeCodeBlock=100/0,// This will throw ArithmeticException
errorMessage=Some("Cannot divide by zero"),exceptionHandlingCodeblock=Some(-999)// Fallback value
)resultmatch{caseRight(value)=>println(s"Success: $value")caseLeft(fallback)=>println(s"Failed, using fallback: $fallback")// Prints -999
}
Type-safe: The compiler ensures you handle both success and failure
Reusable: Write once, use everywhere
Testable: Easy to mock and verify error paths
Consistent: Everyone uses the same error handling strategy
Explicit: Either[C, T] forces you to acknowledge failure is possible
This is the foundation. Every other utility builds on this pattern: “Wrap complexity in a function, make it generic, return something that forces the caller to think.”
Part 2 - Implicit classes: Extending types without inheritance
WHY we need implicit conversions
Imagine you have Java types (java.util.List, java.util.Optional) but you want Scala collections (List, Option). You could write conversion functions:
This is extension methods via implicit classes. You’re not modifying java.util.List - you’re telling the compiler: “If someone calls .toScala on a Java List, secretly wrap it in my converter class.”
HOW implicit classes work
An implicit class is a class that the compiler automatically instantiates when it sees a method call that doesn’t exist on the original type.
The pattern:
1
2
3
4
5
implicitclassMyExtensions[T](value:T){defnewMethod:SomeType={// Use 'value' to compute result
}}
What the compiler does:
You write: someValue.newMethod
Compiler checks: Does someValue’s type have newMethod? No.
Compiler looks for implicit class that:
Takes someValue’s type as constructor parameter
Defines newMethod
Compiler rewrites your code as: new MyExtensions(someValue).newMethod
It’s syntactic sugar that makes code readable while keeping types separate.
sequenceDiagram
actor Dev
participant Compiler
participant ImplicitClass as Implicit Class
Dev->>Compiler: javaList.toScala
Compiler->>Compiler: Check: does javaList have toScala?
Compiler->>Compiler: No! Search for implicit class
Compiler->>ImplicitClass: Found JavaToScalaList
Compiler->>Compiler: Rewrite as: new JavaToScalaList(javaList).toScala
ImplicitClass-->>Dev: Returns Scala List
importcom.vitthalmirji.dataengineering.common.Converters._caseclassEmployee(id:Int,name:String,age:Int)caseclassOffice(name:String,employees:Array[Employee])valjsonString:Option[String]=Some("""
{
"name": "Acme Corp",
"employees": [
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25}
]
}
""")// Parse JSON to case class
valoffice:Option[Office]=jsonString.jsonStringAs[Office]()officematch{caseSome(o)=>println(s"Office: ${o.name}, Employees: ${o.employees.length}")caseNone=>println("Failed to parse JSON")}
Why this matters in data pipelines:
Many data sources store metadata as JSON strings:
1
2
3
4
5
6
7
8
9
10
// From a config table
valconfigJson:Option[String]=spark.table("configs").filter($"key"==="pipeline_params").select($"value").as[String].headOption// Parse directly to case class
caseclassPipelineConfig(batchSize:Int,retries:Int,timeoutSec:Int)valconfig:Option[PipelineConfig]=configJson.jsonStringAs[PipelineConfig]()
No manual parsing, no missing fields, type-safe.
Part 3 - String interpolators: Making date parsing readable
The format string is error-prone (MM vs mm? HH vs hh?)
It’s unreadable - the intent (parse a date) is buried
Quick Win
String interpolators aren’t just for dates - use them for any repeated pattern. SQL fragments with consistent escaping. S3/GCS paths with validation. Regex patterns with documented intent. If you’re copying the same string construction code 3+ times, write an interpolator. Five minutes now saves hours of debugging “why did that parse fail?” later.
valcentralTime=tz"US/Central"valpacificTime=tz"US/Pacific"// Convert timestamp with timezone
valzonedTs=yearMonthDate24HrNanoTsZone"2025-06-28 14:30:00.123 US/Pacific"
Comparing timestamps:
1
2
3
4
5
6
valcutoffTime=yearMonthDate24HrTs"2025-06-28 00:00:00"valrecordTime=yearMonthDate24HrTs"2025-06-28 14:30:00"if(recordTime.isAfter(cutoffTime)){println("Record is recent")}
Why this matters
Readability: yearMonthDate"2025-06-28" vs LocalDate.parse(...)
Consistency: Everyone uses the same format, no mistakes
Compile-time safety: If the string doesn’t match the pattern, it fails at runtime immediately (not buried in logs)
Refactoring-friendly: Change the format in ONE place (the interpolator definition)
This pattern works for ANY domain-specific syntax: SQL fragments, URLs, regex, file paths. If you’re repeating string patterns, write an interpolator.
Part 3.5 - GCS String Interpolators: Working with Cloud Storage like a pro
WHY GCS operations are painful
Standard GCS operations in Scala require verbose Java SDK calls:
1
2
3
4
5
6
7
8
importcom.google.cloud.storage.{Storage,StorageOptions,BlobId,Blob}valstorage=StorageOptions.getDefaultInstance.getServicevalblobId=BlobId.of("my-bucket","path/to/file.parquet")valblob=storage.get(blobId)// Get timestamp
vallastModified=blob.getUpdateTime
After writing this 100 times in data pipelines, you realize:
You’re repeating the storage service initialization
BlobId construction is verbose for every file access
No type safety - bucket/path typos fail at runtime
Listing blobs requires understanding Pages and iterators
caseclassGcsObject(projectId:Option[String],bucketName:String,objectName:Option[String])implicitclassGcsObjectConversions(gcsURI:String){deftoGcsObject:GcsObject={valtokens=if(gcsURI.startsWith("gs://")){gcsURI.substring(5).split("/",2)}else{gcsURI.split("/",2)}if(tokens.isEmpty)thrownewIllegalArgumentException(s"Invalid GCS URI $gcsURI")elseif(tokens.tail.isEmpty)GcsObject(None,tokens.head,None)elseGcsObject(None,tokens.head,Some(tokens.tail.mkString("/")))}}
WHAT the complete GCS interpolator suite looks like
blob - Single file access:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
defblob(args:Any*):Blob={valtotalString=sc.s(args:_*).trimvalgcsObject=totalString.toGcsObjectif(gcsObject.objectName.isEmpty){thrownewIllegalArgumentException("Path of object is empty")}valtryResult=trySafely(STORAGE_SERVICE.get(gcsObject.bucketName,gcsObject.objectName.get,BlobGetOption.fields(Storage.BlobField.values():_*)),Some(s"Error fetching blob $args"))tryResult.merge}
flowchart TB
A[GCS URI String] --> B{Parse with toGcsObject}
B --> C[GcsObject]
C --> D{Interpolator Type}
D -->|blob| E["Single Blob<br/>blob"gs://bucket/file""]
D -->|blobs| F["Page of Blobs<br/>blobs"gs://bucket/prefix""]
D -->|manyBlobs| G["Map GcsObject → Page Blob<br/>manyBlobs"gs://b1/,gs://b2/""]
D -->|bucket| H["Single Bucket<br/>bucket"my-bucket""]
D -->|buckets| I["List of Buckets<br/>buckets"""]
E --> J[GCS Storage API]
F --> J
G --> J
H --> J
I --> J
style J fill:#90EE90,stroke:#2d7a2d,color:#000
valallBuckets=buckets""println(s"Total buckets in project: ${allBuckets.size}")allBuckets.foreach{bucket=>println(s"- ${bucket.getName}")}
Why this matters
Readability: blob"gs://bucket/file" vs 5 lines of SDK boilerplate
Type safety: Parse errors at string interpolation time
Consistency: Everyone uses the same GCS access pattern
Integration: Works seamlessly with affected partitions logic
Error handling: Built-in trySafely wrapping
Part 4 - Change Data Capture (CDC): SCD Type 1 with Spark
WHY CDC matters
In data warehousing, Change Data Capture tracks changes between dataset versions:
Inserts (new rows)
Updates (modified rows)
Deletes (missing rows)
No change (identical rows)
Slowly Changing Dimension Type 1 (SCD Type 1) means: overwrite old values with new ones, don’t keep history.
Use case: You have a customers table. Every night:
Load fresh customer data from source
Compare with yesterday’s table
Mark each row as: I (insert), U (update), D (delete), or NC (no change)
Write the delta
HOW Delta CDC works
The algorithm:
Compute a hash of all non-key columns for both datasets
Full outer join on primary keys
Compare hashes to detect changes:
If left-only: Insert
If right-only: Delete
If hashes match: No change
If hashes differ: Update
Why hash instead of column-by-column comparison?
Performance: One hash comparison vs N column comparisons
Simplicity: Hash mismatch = something changed, don’t care what
Deterministic: Same data → same hash
flowchart TB
A[Source DataFrame] --> B[Compute Hash]
C[Previous DataFrame] --> D[Compute Hash]
B --> E{Full Outer Join<br/>on Primary Keys}
D --> E
E -->|Left Only| F[Insert - I]
E -->|Right Only| G[Delete - D]
E -->|Both: Hash Match| H[No Change - NC]
E -->|Both: Hash Differs| I[Update - U]
F --> J[Delta DataFrame]
G --> J
H --> J
I --> J
style F fill:#90EE90,stroke:#2d7a2d,color:#000
style G fill:#FFB6C6,stroke:#cc0066,color:#000
style H fill:#87CEEB,stroke:#0066cc,color:#000
style I fill:#FFD700,stroke:#cc8800,color:#000
importcom.vitthalmirji.dataengineering.spark.ETL._valsourceDf=spark.table("source.customers").withColumn("load_ts",current_timestamp).withColumn("upd_ts",current_timestamp)valdeltaDf=sourceDf.performDelta(previousLoadedDataset=None,// No previous data
primaryKeys=List("customer_id"))deltaDf.write.mode("overwrite").saveAsTable("warehouse.customers")//Allrowshavedelta_flag='I'
objectDeltaTableUtils{defexecuteVacuum(tableName:String,retentionHours:Double=168.0// 7 days default
):Boolean={valspark:SparkSession=SparkSession.getActiveSession.getspark.conf.set("spark.databricks.delta.vacuum.parallelDelete.enabled","true")try{valresult=DeltaTable.forName(tableName).vacuum(retentionHours)if(result.isEmpty){logger.info(s"Vacuum successful for $tableName, retention=$retentionHours hours")true}else{logger.error(s"Vacuum failed for $tableName")false}}catch{casee:Throwable=>logger.error(s"Vacuum over $tableName failed: ${e.getMessage}")throwe}}}
What’s happening:
Enable parallel delete for faster vacuuming
Use Delta Lake’s DeltaTable.forName() API (type-safe table lookup)
Call .vacuum(retentionHours) - deletes files older than threshold
Return success/failure
WHY vacuum matters:
Delta keeps files for time travel (SELECT * FROM table VERSION AS OF 5)
Old files consume storage
Vacuum deletes files no longer needed
Warning: After vacuum, you can’t query versions older than retention period
defexecuteCompaction(tableName:String,partitionFilterExpr:Option[String]=None):Boolean={try{valoptimizeDf=DeltaTable.forName(tableName).optimize()partitionFilterExpr.map(filterExpr=>optimizeDf.where(filterExpr)).getOrElse(optimizeDf).executeCompaction()true}catch{caset:Throwable=>logger.error(s"Compaction failed for $tableName: ${t.getMessage}")throwt}}
What’s happening:
Call .optimize() on Delta table
Optionally filter to specific partitions (e.g., "date >= '2025-06-01'")
Execute compaction - merges small files into larger ones
WHY compaction matters:
Streaming writes create many small files
Reading 1000 small files is slow (file open overhead)
defexecuteZOrderBy(tableName:String,zOrderByKeys:Option[Seq[String]]=None,partitionFilterExpr:Option[String]=None):Boolean={try{if(zOrderByKeys.isDefined&&zOrderByKeys.get.isEmpty){logger.error(s"Invalid zOrderBy keys: ${zOrderByKeys.get}")returnfalse}valoptimizeDf=DeltaTable.forName(tableName).optimize()valtoCompactDf=partitionFilterExpr.map(filterExpr=>optimizeDf.where(filterExpr)).getOrElse(optimizeDf)zOrderByKeys.map(keys=>toCompactDf.executeZOrderBy(keys:_*)).getOrElse(toCompactDf.executeZOrderBy())true}catch{caset:Throwable=>logger.error(s"Z-ordering failed for $tableName: ${t.getMessage}")throwt}}
What’s happening:
Validate Z-order keys are provided
Call .optimize() on Delta table
Optionally filter to specific partitions
Execute Z-ordering on specified columns
Performance win
Z-ordering isn’t magic, but the wins are real. One team Z-ordered their 5TB transactions table by customer_id and date - queries that scanned 500GB before now scan 50GB. Same query, 10x less data read, 70% faster execution. The catch: only works if your queries actually filter on those Z-order columns. Profile first, then optimize.
WHY Z-ordering matters:
Z-ordering is a multi-dimensional clustering technique. It co-locates related data in the same files.
Example: You have a transactions table with columns customer_id, date, amount.
Without Z-ordering:
File 1: customers A, B, C from dates 2025-01-01 to 2025-06-30
File 2: customers D, E, F from dates 2025-01-01 to 2025-06-30
Query: WHERE customer_id = 'A' AND date = '2025-06-15'
Reads: Both files (because customer A is in file 1, but scattered across many row groups)
With Z-ordering on (customer_id, date):
File 1: customer A, dates 2025-01-01 to 2025-03-31
File 2: customer A, dates 2025-04-01 to 2025-06-30
File 3: customer B, dates 2025-01-01 to 2025-03-31
Query: WHERE customer_id = 'A' AND date = '2025-06-15'
Reads: Only file 2 (customer A’s June data is co-located)
Result: 70-90% less data scanned on filtered queries.
graph LR
A[Without Z-Ordering] --> B[File 1: Customers A-F<br/>Dates: Jan-Jun]
A --> C[File 2: Customers G-M<br/>Dates: Jan-Jun]
D[With Z-Ordering<br/>on customer_id, date] --> E[File 1: Customer A<br/>Dates: Jan-Mar]
D --> F[File 2: Customer A<br/>Dates: Apr-Jun]
D --> G[File 3: Customer B<br/>Dates: Jan-Mar]
H[Query: WHERE customer_id='A'<br/>AND date='2025-06-15'] --> B
H --> C
I[Same Query with Z-Order] --> F
style B fill:#FFB6C6,stroke:#cc0066,color:#000
style C fill:#FFB6C6,stroke:#cc0066,color:#000
style F fill:#90EE90,stroke:#2d7a2d,color:#000
B -.->|Reads both files| J[Slow: 2 files]
C -.->|Reads both files| J
F -.->|Reads 1 file| K[Fast: 1 file]
style J fill:#FFB6C6,stroke:#cc0066,color:#000
style K fill:#90EE90,stroke:#2d7a2d,color:#000
Usage in pipelines
Pattern 1: Vacuum after batch load
1
2
3
4
5
6
7
8
9
10
11
importcom.vitthalmirji.dataengineering.spark.DeltaTableUtils._// Load data
sourceDf.write.mode("append").saveAsTable("warehouse.transactions")// Vacuum old versions (keep last 7 days)
valvacuumSuccess=executeVacuum("warehouse.transactions",retentionHours=168.0)if(!vacuumSuccess){logger.warn("Vacuum failed - old files still present, but load succeeded")}
Pattern 2: Compact on schedule
1
2
3
4
5
6
7
8
9
10
11
// Run compaction nightly for today's partition
valtoday=LocalDate.now().toStringvalcompactSuccess=executeCompaction(tableName="warehouse.transactions",partitionFilterExpr=Some(s"date = '$today'"))if(compactSuccess){logger.info("Compaction complete - small files merged")}
Pattern 3: Z-order on commonly filtered columns
1
2
3
4
5
6
7
8
9
10
// Z-order by customer_id and date (common filter combo)
valzOrderSuccess=executeZOrderBy(tableName="warehouse.transactions",zOrderByKeys=Some(Seq("customer_id","date")),partitionFilterExpr=Some(s"date >= '2025-01-01'")// Only recent data
)if(zOrderSuccess){logger.info("Z-ordering complete - queries on customer_id/date will be faster")}
Pattern 4: Combined maintenance workflow
flowchart TB
A[Delta Table with Issues] --> B[1000s of small files<br/>Slow queries<br/>Old versions]
B --> C[Step 1: Compaction]
C --> D[Merge small files<br/>→ ~1GB files]
D --> E[Step 2: Z-Ordering]
E --> F[Co-locate related data<br/>by customer_id, date]
F --> G[Step 3: Vacuum]
G --> H[Delete files older<br/>than 7 days]
H --> I[Optimized Table]
I --> J[✓ Fewer files<br/>✓ Faster queries<br/>✓ Less storage]
style A fill:#FFB6C6,stroke:#cc0066,color:#000
style I fill:#90EE90,stroke:#2d7a2d,color:#000
style J fill:#90EE90,stroke:#2d7a2d,color:#000
defmaintainDeltaTable(tableName:String,zOrderCols:Seq[String]):Unit={logger.info(s"Starting maintenance for $tableName")// Step 1: Compact small files
if(!executeCompaction(tableName)){thrownewRuntimeException(s"Compaction failed for $tableName")}// Step 2: Z-order for query performance
if(!executeZOrderBy(tableName,Some(zOrderCols))){thrownewRuntimeException(s"Z-ordering failed for $tableName")}// Step 3: Vacuum old files
if(!executeVacuum(tableName,retentionHours=168.0)){logger.warn(s"Vacuum failed for $tableName - old files remain")}logger.info(s"Maintenance complete for $tableName")}// Usage
maintainDeltaTable("warehouse.customers",Seq("customer_id","region"))maintainDeltaTable("warehouse.transactions",Seq("customer_id","date"))
Part 6 - Schema evolution: Automatic column add/drop with validation
WHY schema evolution is hard
Production tables change over time:
New columns added (source adds phone_number field)
Columns removed (deprecated fax_number field)
Primary keys must stay stable (can’t change identity)
Partitions must stay stable (can’t change partitioning scheme)
Manual schema evolution:
1
2
3
4
5
6
7
8
// Add column manually
spark.sql("ALTER TABLE warehouse.customers ADD COLUMN phone_number STRING")// Backfill existing rows with NULL
spark.sql("UPDATE warehouse.customers SET phone_number = NULL WHERE phone_number IS NULL")// Drop column manually
spark.sql("ALTER TABLE warehouse.customers DROP COLUMN fax_number")
Problems:
No validation - you can accidentally drop a primary key
No automation - you manually detect schema changes
No history - you can’t roll back if something breaks
Error-prone - typos, wrong table, wrong column
HOW to automate schema evolution
We need utilities that:
Detect schema changes - compare source vs target schemas
Return DataFrame with only new columns + primary keys
WHY this matters:
When adding columns, you need to backfill existing rows. Instead of updating in place:
1
2
// ❌ BAD: Full table update (slow, locks table)
spark.sql("UPDATE warehouse.customers SET phone_number = NULL")
We join new columns with existing data:
1
2
3
4
5
6
7
8
9
// ✅ GOOD: Join approach (fast, no locks)
valnewColsDf=getNewColumnsDf(targetDf,sourceDf,primaryKeys=Seq("customer_id"))valevolvedDf=targetDf.join(newColsDf,Seq("customer_id"),"left").selectExpr(sourceDf.columns:_*)// Reorder to match source schema
// Write back
evolvedDf.write.mode("overwrite").saveAsTable("warehouse.customers")
caseclassSchemaEvolution(sourceDf:DataFrame,targetTable:String,primaryKeys:Seq[String])defevolveNonPartitionedTable(input:SchemaEvolution):Unit={valtargetDf=spark.table(input.targetTable)// Step 1: Detect changes
valchangeMap=getChangeColumns(targetDf,input.sourceDf)valcolumnsToAdd=changeMap.getOrElse("COLS_TO_ADD",Seq())valcolumnsToDrop=changeMap.getOrElse("COLS_TO_DROP",Seq())if(columnsToAdd.isEmpty&&columnsToDrop.isEmpty){thrownewException("No schema changes detected")}// Step 2: Validate primary keys didn't change
if(input.primaryKeys.exists(columnsToAdd.contains)||input.primaryKeys.exists(columnsToDrop.contains)){thrownewException("Primary key changes not allowed")}// Step 3: Build evolved DataFrame
valevolvedDf=if(columnsToAdd.nonEmpty){valnewColsDf=getNewColumnsDf(targetDf,input.sourceDf,input.primaryKeys)targetDf.join(newColsDf,input.primaryKeys,"left")}else{targetDf// Just dropping columns, no join needed
}// Step 4: Write with overwriteSchema
evolvedDf.selectExpr(input.sourceDf.columns:_*).write.mode("overwrite").option("overwriteSchema","true").saveAsTable(input.targetTable)logger.info(s"Schema evolution complete for ${input.targetTable}")}defgetChangeColumns(tgtDf:DataFrame,sourceDf:DataFrame):Map[String, Seq[String]]={valsourceCols=sourceDf.columnsvaltargetCols=tgtDf.columnsvalnewCols=sourceCols.filterNot(targetCols.toSet).toListvaldroppedCols=targetCols.filterNot(sourceCols.toSet).toListMap("COLS_TO_ADD"->newCols,"COLS_TO_DROP"->droppedCols)}
What’s happening:
Detect changes: Compare source vs target schemas
Validate: Ensure primary keys aren’t touched
Add columns: Join new columns from source (NULLs backfilled)
Drop columns: Select only source columns (effectively drops extra columns)
Write with overwriteSchema: Tells Delta to accept schema changes
Schema evolution danger
Never evolve partition columns or primary keys in production. If you add/drop/rename a partition column, every downstream job reading that table breaks. If you change primary keys, CDC logic produces garbage. Schema evolution is for adding business columns, not changing table structure. Changing structure means creating a new table and migrating.
For partitioned tables:
Partitioned tables need special handling:
Can’t use overwriteSchema on partitioned writes (Delta limitation)
defevolvePartitionedTable(input:SchemaEvolution,partitionCols:Seq[String]):Unit={valtargetDf=spark.table(input.targetTable)valchangeMap=getChangeColumns(targetDf,input.sourceDf)valcolumnsToAdd=changeMap.getOrElse("COLS_TO_ADD",Seq())valcolumnsToDrop=changeMap.getOrElse("COLS_TO_DROP",Seq())// Validate partitions didn't change
if(partitionCols.exists(columnsToAdd.contains)||partitionCols.exists(columnsToDrop.contains)){thrownewException("Partition column changes not allowed")}// Validate primary keys include partitions
if(!partitionCols.forall(input.primaryKeys.contains)){thrownewException("Partition columns must be part of primary keys")}// Add columns: Use mergeSchema
if(columnsToAdd.nonEmpty){valnewColsDf=getNewColumnsDf(targetDf,input.sourceDf,input.primaryKeys)valevolvedDf=targetDf.join(newColsDf,input.primaryKeys,"left")evolvedDf.selectExpr(input.sourceDf.columns:_*).write.mode("overwrite").partitionBy(partitionCols:_*).option("mergeSchema","true").saveAsTable(input.targetTable)}// Drop columns: Use ALTER TABLE
if(columnsToDrop.nonEmpty){// Enable column mapping (required for column drops)
spark.sql(s"""
ALTER TABLE ${input.targetTable} SET TBLPROPERTIES (
'delta.minReaderVersion' = '2',
'delta.minWriterVersion' = '5',
'delta.columnMapping.mode' = 'name'
)
""")// Drop columns
valdropList=columnsToDrop.mkString(",")spark.sql(s"ALTER TABLE ${input.targetTable} DROP COLUMNS ($dropList)")}logger.info(s"Partitioned schema evolution complete for ${input.targetTable}")}
Why different strategies?
Adding columns to partitioned table: Can’t use overwriteSchema (breaks partition evolution), must use mergeSchema
Dropping columns: Delta needs column mapping enabled first (so it can track columns by ID, not name)
Primary keys must include partitions: Otherwise you can’t uniquely identify rows within a partition
flowchart TB
A[Source DataFrame] --> B{isSchemaChanged?}
B -->|No| C[Normal Load]
B -->|Yes| D[getChangeColumns]
D --> E{Validate}
E -->|Primary Keys Changed| F[ABORT:<br/>Not Allowed]
E -->|Partitions Changed| F
E -->|Valid Changes| G{Column Changes}
G -->|Add Columns| H[Join new columns<br/>from source]
G -->|Drop Columns| I[Select only<br/>source columns]
G -->|Both| J[Join + Select]
H --> K{Partitioned?}
I --> K
J --> K
K -->|No| L[Write with<br/>overwriteSchema]
K -->|Yes - Add| M[Write with<br/>mergeSchema]
K -->|Yes - Drop| N[ALTER TABLE<br/>DROP COLUMNS]
L --> O[Schema Evolved]
M --> O
N --> O
style F fill:#FFB6C6,stroke:#cc0066,color:#000
style O fill:#90EE90,stroke:#2d7a2d,color:#000
No centralized config (rules scattered across pipelines)
No reporting (can’t see DQ trends over time)
DQ integration note
The DQ wrapper pattern works with any rule engine - Great Expectations, Deequ, custom frameworks, or commercial tools. The key is centralizing rule configuration (database, config files, whatever) and making execution one-line (.executeDQ). Swap out the rule engine implementation without changing pipeline code. Pick what fits your stack.
Solution: Data Quality frameworks (like Great Expectations, Deequ, or proprietary DQ libraries) let you define rules centrally and execute them declaratively.
But integrating these frameworks still requires boilerplate:
objectDataQuality{valruleEngine=newRuleEngine()// Your DQ framework
implicitclassDataQualityActions(dataframe:Dataset[Row])extendsSerializable{defexecuteDQ(ruleSetId:Int,emailDetails:Option[Email]=None,emailOnSuccess:Boolean=true,tableName:Option[String]=None):Boolean={// Register rules from centralized config
ruleEngine.registerRuleset(ruleSetId,Environment.PROD)// Execute all rules against DataFrame
valmetrics=ruleEngine.executeRules(dataframe)// Check if any rule failed
valisSuccess=!metrics.exists(_.getFailedCount>0)// Send email if configured
if(emailDetails.isDefined&&(!isSuccess||(isSuccess&&emailOnSuccess))){valrules=ruleEngine.getRulesruleEngine.generateAndSendEmail(emailDetails.get,rules,metrics,tableName)}// Clear rules for next run
ruleEngine.clearRules()isSuccess}}}
What’s happening:
Register ruleset: Load rules from centralized config (e.g., rule ID 200 = “customer validation rules”)
Execute rules: Run all rules against the DataFrame
Check results: Any rule with failed_count > 0 means DQ failed
Send email: Optionally email results (always on failure, optionally on success)
Clear rules: Clean up for next execution
Return Boolean: True = all passed, False = at least one failed
WHY this design?
Implicit class: Makes DQ checks read like native Spark operations
Rule ID: Centralized config (change rules without code changes)
Email integration: Built-in reporting
Boolean return: Simple pass/fail for pipeline control flow
sequenceDiagram
participant Pipeline
participant DataFrame
participant DQ as DataQuality.executeDQ
participant Engine as RuleEngine
participant Email as EmailService
participant DB as AuditDB
Pipeline->>DataFrame: transformedDf
DataFrame->>DQ: .executeDQ(ruleSetId=200)
DQ->>Engine: registerRuleset(200)
Engine-->>DQ: Rules loaded
DQ->>Engine: executeRules(dataframe)
Engine-->>DQ: metrics
alt All Rules Pass
DQ->>Email: Send success email (optional)
DQ->>DB: Write metrics (optional)
DQ-->>Pipeline: true
Pipeline->>Pipeline: Write to warehouse
else Any Rule Fails
DQ->>Email: Send failure email
DQ->>DB: Write metrics (optional)
DQ-->>Pipeline: false
Pipeline->>Pipeline: Write to staging/abort
end
Usage in pipelines
Pattern 1: Basic DQ check
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
importcom.vitthalmirji.dataengineering.spark.DataQuality._valtransformedDf=sourceDf.filter($"amount">0).groupBy($"customer_id").agg(sum($"amount").alias("total"))// Execute DQ checks (ruleset 200 = customer totals validation)
valisValid=transformedDf.executeDQ(ruleSetId=200)if(isValid){transformedDf.write.mode("overwrite").saveAsTable("warehouse.customer_totals")}else{thrownewRuntimeException("DQ checks failed - NOT writing to warehouse")}
importcom.vitthalmirji.dataengineering.spark.DataQuality._importcom.vitthalmirji.dataengineering.common.Emailvalemail=Email(from="[email protected]",to=List("[email protected]"),subject=Some("DQ Report: Customer Totals"),body=None// Auto-generated by DQ framework
)valisValid=transformedDf.executeDQ(ruleSetId=200,emailDetails=Some(email),emailOnSuccess=false,// Only email on failure
tableName=Some("warehouse.customer_totals"))if(!isValid){logger.error("DQ failed - check email for details")thrownewRuntimeException("DQ checks failed")}
Pattern 3: DQ with fallback (write to staging on failure)
1
2
3
4
5
6
7
8
9
10
11
valisValid=transformedDf.executeDQ(ruleSetId=200)if(isValid){// Write to production
transformedDf.write.mode("overwrite").saveAsTable("warehouse.customers")logger.info("DQ passed - written to warehouse")}else{// Write to staging for investigation
transformedDf.write.mode("overwrite").saveAsTable("staging.customers_failed_dq")logger.warn("DQ failed - written to staging for review")}
defexecuteDQWithDBWrite(ruleSetId:Int,emailDetails:Option[Email]=None,emailOnSuccess:Boolean=false,targetTable:String,dbProps:Properties,batchId:Long=0):Boolean={val(isSuccess,rules,metrics)=getDQResult(ruleSetId=ruleSetId,emailDetails=emailDetails,emailOnSuccess=emailOnSuccess,tableName=Some(targetTable))// Write DQ results to audit database
writeDQResultToDB(targetTable,metrics,rules,dbProps,batchId)isSuccess}// Usage
valdbProps=newProperties()dbProps.setProperty("url","jdbc:postgresql://localhost:5432/audit")dbProps.setProperty("user","audit_writer")dbProps.setProperty("password","***")valisValid=transformedDf.executeDQWithDBWrite(ruleSetId=200,targetTable="warehouse.customers",dbProps=dbProps,batchId=12345)// Later: Query audit DB to see DQ trends over time
spark.read.jdbc(dbProps,"dq_audit_table").filter($"target_table"==="warehouse.customers").groupBy("run_date").agg(sum("failed_count").alias("total_failures")).orderBy($"run_date".desc).show()
Why this matters:
Consistency: All pipelines use the same DQ framework
Centralized rules: Change validation logic without code changes
Auditability: DQ results stored in database for compliance
Fail-fast: Catch bad data before it reaches warehouse
Alerting: Automatic email on failures
Part 8 - Table operations: Backup, partitions, and location discovery
WHY table operations are hard
Spark makes it easy to query tables, but production pipelines need more:
Backup tables before risky operations
Drop specific partitions without dropping the whole table
Find table locations to copy files directly
Repair partition metadata after manual file operations
Standard Spark doesn’t provide these out of the box. You end up with:
1
2
3
4
5
6
7
8
9
10
11
// Find location
vallocation=spark.sql("DESC FORMATTED my_table").filter("col_name = 'Location'").select("data_type").first().getAs[String]("data_type")// Drop partitions
spark.sql("ALTER TABLE my_table DROP IF EXISTS PARTITION(date='2025-01-01') PURGE")//Backup-what,copyfilesmanually?
This is repetitive, error-prone, and not reusable.
Data loss risk
Always test backup utilities on non-production data first. The partition path extraction logic assumes standard Hive-style paths (warehouse.db/table/partition=value/). If your tables use custom locations or non-standard partitioning, you could silently copy files to wrong destinations or skip partitions entirely. Validate with a single partition before scaling up.
HOW to build table utilities
We’ll use the same pattern as before: implicit classes that extend DataFrame with table-specific operations.
The key insight: A DataFrame queried from a table “knows” which table it came from (via input file names). We can extract that metadata.
implicitclassETLDataframeActions(dataframe:DataFrame)extendsSerializable{defbackup(backupLocationRootPath:String):Boolean={valspark:SparkSession=SparkSession.getActiveSession.getvalFILE_LOCATION="file_location"valtargetLocation=spark.sparkContext.broadcast(backupLocationRootPath)importspark.implicits.newStringEncoder// Step 1: Get all file locations from the DataFrame
valfileLocations=dataframe.select(input_file_name().alias(FILE_LOCATION)).distinct().map(_.getAs[String](FILE_LOCATION)).rdd.mapPartitions{partition=>partition.map{location=>// Step 2: Extract partition path from full location
vallocationTokens=location.split("/")valtablePath=locationTokens.slice(locationTokens.indexWhere(_.contains(".db"))+2,locationTokens.length).mkString("/")// Step 3: Build target path
valtargetPath=if(targetLocation.value.endsWith("/")){targetLocation.value+tablePath}else{targetLocation.value+"/"+tablePath}(location,targetPath)}}// Step 4: Copy all files in parallel
parallelCopy(fileLocations)}}
How this works step-by-step:
1. Get source file locations:
input_file_name() is a Spark function that returns the full path of each input file
For a partitioned table: gs://bucket/warehouse.db/customers/date=2025-01-01/part-00000.parquet
.distinct() because multiple rows can come from the same file
2. Extract partition structure:
1
2
3
4
5
// Input: gs://bucket/warehouse.db/customers/date=2025-01-01/part-00000.parquet
// Split by /: ["gs:", "", "bucket", "warehouse.db", "customers", "date=2025-01-01", "part-00000.parquet"]
// Find .db: index 3
// Slice from .db+2 to end: ["date=2025-01-01", "part-00000.parquet"]
//Join:"date=2025-01-01/part-00000.parquet"
This preserves the partition structure in the backup.
flowchart TB
A[DataFrame] --> B[input_file_name]
B --> C[Extract File Locations]
C --> D["gs://bucket/warehouse.db/<br/>customers/date=2025-01-01/<br/>part-00000.parquet"]
D --> E[Split by '/']
E --> F[Find '.db' index]
F --> G[Slice from .db+2 to end]
G --> H["date=2025-01-01/<br/>part-00000.parquet"]
H --> I[Combine with backup path]
I --> J["gs://backup-bucket/<br/>customers_backup/<br/>date=2025-01-01/<br/>part-00000.parquet"]
J --> K[Parallel Copy<br/>via Spark RDD]
style K fill:#90EE90,stroke:#2d7a2d,color:#000
3. Build target path:
1
2
// If target = "gs://backup/customers_backup/"
//Result="gs://backup/customers_backup/date=2025-01-01/part-00000.parquet"
4. Parallel copy:
Uses Spark’s RDD parallelism to copy files across executors
implicitclassImplicitTablePartitionActions(tablePartitions:List[Partition])extendsSerializable{defdrop(tableName:String):Unit={valpartitionList=getPartitions(inferType=true)valpartitionString=partitionList.map(partition=>s"PARTITION(${partition.mkString(",")})").mkString(",")valquery=s"ALTER TABLE $tableName DROP IF EXISTS $partitionString PURGE"logger.warn(s"Dropping partitions using query: $query")SparkSession.getActiveSession.foreach(_.sql(query).show())}defdelete(tableName:String):Unit={valpartitionList=getPartitions(inferType=false)logger.warn(s"Attempting to delete partitions $partitionList")valtableLocation=getTableLocation(tableName)valdfsLocations=partitionList.map(partition=>tableLocation+"/"+partition.mkString("/"))dfsLocations.foreach(deleteDfsLocation)}}
What’s a Partition?
1
2
3
4
5
caseclassPartition(partitions:Map[String, Any])// Example
valpart1=Partition(Map("date"->"2025-01-01"))valpart2=Partition(Map("date"->"2025-01-02","region"->"US"))
importcom.vitthalmirji.dataengineering.spark.Table._// Get partitions to drop
valtargetTable=spark.table("warehouse.sales")valpartitionsToDrop=targetTable.filter($"date"<"2025-01-01")// Old data
.select("date","region").distinct().collect().map(row=>Partition(Map("date"->row.getAs[String]("date"),"region"->row.getAs[String]("region")))).toList// Drop metadata
partitionsToDrop.drop("warehouse.sales")// Delete files
partitionsToDrop.delete("warehouse.sales")// Repair metadata
repairRefreshTable("warehouse.sales")
Part 8.5 - GCS Object Operations: Copy, move, timestamps, and parallel operations
WHY blob operations need utilities
Working with GCS blobs in production pipelines requires common operations:
defparallelCopy(fileLocations:RDD[(String, String)]):Boolean={valcopyResult=fileLocations.mapPartitions(partitionAttribute=>{partitionAttribute.map{case(source,destination)=>logger.warn(s"Attempting to copy $source to $destination")valsourceBlob=blob"$source"valtarget=destination.toGcsObjectvalcopyResult=sourceBlob.copy(target.bucketName,target.objectName)(sourceBlob,copyResult)}})valfailedCopyBlobs=copyResult.filter(_._2.equals(false))valresult=failedCopyBlobs.take(1).isEmptyif(!result){logger.error("Error copying blobs: "+failedCopyBlobs.collect().map(b=>b._1.getBucket+"/"+b._1.getName).mkString(","))}result}
flowchart LR
A[Source Blob] --> B{Operation}
B -->|getValueAsString| C[Read Content<br/>as String]
B -->|timestamps| D[createdTs<br/>updatedTs<br/>updatedDate]
B -->|isFile| E[Check if File<br/>not directory]
B -->|copy| F[Copy to Target]
B -->|move| G[Copy + Delete Source]
B -->|remove| H[Delete Blob]
F --> I[Target Blob]
G --> I
style C fill:#87CEEB,stroke:#0066cc,color:#000
style D fill:#87CEEB,stroke:#0066cc,color:#000
style E fill:#87CEEB,stroke:#0066cc,color:#000
style I fill:#90EE90,stroke:#2d7a2d,color:#000
style H fill:#FFB6C6,stroke:#cc0066,color:#000
Usage in pipelines
Pattern 1: Backup files with timestamps:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
importcom.vitthalmirji.dataengineering.gcp.GcsInterpolators._importcom.vitthalmirji.dataengineering.gcp.ObjectActions._valsourceFile=blob"gs://source-bucket/data/important.parquet"vallastModified=sourceFile.updatedDate(Some(tz"US/Central"))// Backup with date suffix
valbackupPath=s"gs://backup-bucket/data/important_$lastModified.parquet"valbackupTarget=backupPath.toGcsObjectvalsuccess=sourceFile.copy(backupTarget.bucketName,backupTarget.objectName)if(success){println(s"Backed up file from $lastModified")}
valcutoffDate=yearMonthDate"2025-01-01"valoldFiles=blobs"gs://temp-bucket/staging/"oldFiles.getValues.asScala.filter(_.isFile).filter(blob=>blob.updatedDate(None).isBefore(cutoffDate)).foreach{blob=>blob.remove()println(s"Deleted old file: ${blob.getName}")}
Pattern 4: Read config from GCS:
1
2
3
4
5
6
7
8
valconfigBlob=blob"gs://config-bucket/pipeline-config.json"valconfigJson=Some(configBlob.getValueAsString)// Use with Converters
caseclassPipelineConfig(batchSize:Int,retries:Int)valconfig=configJson.jsonStringAs[PipelineConfig]()config.foreach(c=>println(s"Batch size: ${c.batchSize}"))
Pattern 5: Parallel copy for backup (used by df.backup()):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
importcom.vitthalmirji.dataengineering.gcp.ObjectActions.parallelCopy// Get file locations from DataFrame
valfileLocations:RDD[(String, String)]=df.select(input_file_name().alias("source")).distinct().rdd.map(row=>{valsource=row.getAs[String]("source")valtarget=source.replace("gs://prod-bucket/","gs://backup-bucket/")(source,target)})// Copy all files in parallel across executors
valsuccess=parallelCopy(fileLocations)if(success){println("Parallel backup complete")}
Part 8.7 - Affected partitions: 5 strategies for finding what changed
WHY affected partitions are critical
In production data lakes, you can’t reprocess everything every time. You need to know:
Which partitions changed since last run?
Which files were added/modified in the last 24 hours?
Manually track what changed (error-prone, doesn’t scale)
The challenge: Different use cases need different strategies.
The 5 strategies
flowchart TB
A[Need Affected partitions?] --> B{Use Case}
B -->|Simple: Single table<br/>Date partitions| C[Strategy 1:<br/>DataFrame-based<br/>input_file_name]
B -->|Complex: Nested partitions<br/>Multi-level dirs| D[Strategy 2:<br/>GCS Multi-threaded<br/>Recursive traversal]
B -->|Bucket-level:<br/>All files in bucket| E[Strategy 3:<br/>Bucket timestamp<br/>Parallel pages]
B -->|File-level:<br/>Need file paths| F[Strategy 4:<br/>File-level GCS<br/>Returns paths]
B -->|View-level:<br/>Multiple tables| G[Strategy 5:<br/>View with filters<br/>Per-table partitions]
style C fill:#87CEEB,stroke:#0066cc,color:#000
style D fill:#90EE90,stroke:#2d7a2d,color:#000
style E fill:#FFD700,stroke:#cc8800,color:#000
style F fill:#FFA500,stroke:#cc6600,color:#000
style G fill:#FF69B4,stroke:#cc0066,color:#000
importcom.vitthalmirji.dataengineering.spark.Table._importcom.vitthalmirji.dataengineering.datetime.DateTimeInterpolators._valcustomersDf=spark.table("warehouse.customers")valcutoff=yearMonthDate24HrTs"2025-06-27 00:00:00"valaffectedDates=customersDf.getAffectedPartitionDates(since=cutoff)affectedDates.foreach{case(date,lastModified)=>println(s"Partition $date was modified at $lastModified")// Reprocess only affected partition
valpartitionDf=customersDf.filter($"date"===date.toString)// ... process
}
Pros:
Fast for simple schemas
Uses Spark’s distributed processing
Works with any DataFrame
Cons:
Requires loading DataFrame first
Only works for tables Spark can read
Doesn’t handle nested partitions well
Strategy 2: GCS Multi-threaded (Recursive with Futures)
objectGcsAffectedBlobs{@tailrecdefprocessPages(currentPage:Page[Blob],parentBlobName:String,futures:Vector[Future[ListBuffer[Option[Partition]]]],since:LocalDateTime,executionContext:ExecutionContext):Vector[Future[ListBuffer[Option[Partition]]]]=if(currentPage==null){futures}else{processPages(currentPage.getNextPage,parentBlobName,futures++Vector(getAffectedBlobs(currentPage,parentBlobName,since,executionContext)),since,executionContext)}defgetAffectedBlobs(page:Page[Blob],parentBlobName:String,since:LocalDateTime,executionContext:ExecutionContext):Future[ListBuffer[Option[Partition]]]=Future{valaffectedBlobsBuffer=ListBuffer.empty[Option[Partition]]valfuturesBuffer=ListBuffer.empty[Future[ListBuffer[Option[Partition]]]]breakable{page.getValues.forEach{blob=>if(blob.getName.endsWith("/")&&isValidDir(blob,parentBlobName)){// Recurse into subdirectory
valblobPath=blob.getBucket+"/"+blob.getNamevalblobs=blobs"$blobPath"(Array(Storage.BlobListOption.currentDirectory()))futuresBuffer.append(processPages(blobs,blobPath,Vector.empty,since,executionContext):_*)}elseif(!blob.getName.endsWith("/")&&blob.createdTs(None).isAfter(since)){// Extract partition from path
valpartitionMap=blob.getName.split("/").filter(_.contains("=")).flatMap{eachPartition=>valArray(k,v)=eachPartition.split("=")Array(k->v)}.toMapif(partitionMap.nonEmpty){affectedBlobsBuffer+=Option(Partition(partitionMap))break// Found affected file, stop checking this directory
}}}}valfuturesSeq=Future.sequence(futuresBuffer)Await.result(futuresSeq,Duration.Inf)futuresSeq.value.foreach(result=>result.foreach(list=>list.flatten.foreach(partition=>affectedBlobsBuffer+=partition)))affectedBlobsBuffer.distinct}(executionContext)defisValidDir(blob:Blob,parentBlobName:String):Boolean={valblobPath=blob.getBucket+"/"+blob.getNamevalblobName=blob.getName!blobPath.equalsIgnoreCase(parentBlobName)&&!blobName.contains("_spark_metadata")&&!blobName.contains("_staging")&&!blobName.contains(".hive-staging")}}
// Get files modified in last 24 hours
valrecentBlobs=getAffectedBlobs(bucket="my-data-bucket",after=Some(yearMonthDate24HrTs"2025-06-27 00:00:00"))println(s"Found ${recentBlobs.size} recent files")recentBlobs.foreach{blob=>println(s"${blob.getName} - ${blob.updatedTs(None)}")}// Get files in date range (between timestamps)
valrangeBlobs=getAffectedBlobs(bucket="my-data-bucket",before=Some(yearMonthDate24HrTs"2025-06-28 00:00:00"),after=Some(yearMonthDate24HrTs"2025-06-27 00:00:00"),operator="AND")
caseclassMultiTablePartition(tableName:String,partitions:Array[Option[Partition]])defgetAffectedPartitions(sourceView:String,since:LocalDateTime,geoRegionFilters:Seq[String]):Array[MultiTablePartition]={valspark=SparkSession.getActiveSession.getvalviewDf=spark.table(sourceView)// Extract tables from view
valtableSeq=getScannedTableFromView(viewDf,geoRegionFilters)valtablesAffectedPartitions=tableSeq.map(table=>MultiTablePartition(table,getAffectedPartitions(table,since)))tablesAffectedPartitions.toArray}defgetScannedTableFromView(viewDf:DataFrame,geoRegionFilters:Seq[String]):Seq[String]={// Parse query plan to extract table names
// Apply filters if provided
// Return list of table names
???// Implementation details omitted for brevity
}
// View: warehouse.all_transactions (unions US, MX, CA tables)
valaffectedTables=getAffectedPartitions(sourceView="warehouse.all_transactions",since=yearMonthDate24HrTs"2025-06-27 00:00:00",geoRegionFilters=Seq("US","MX")// Only check US and MX tables
)affectedTables.foreach{caseMultiTablePartition(tableName,partitions)=>println(s"Table: $tableName")println(s"Affected partitions: ${partitions.length}")partitions.foreach{caseSome(Partition(partMap))=>println(s" - ${partMap.mkString(", ")}")caseNone=>}// Reprocess each table's affected partitions
partitions.foreach{caseSome(Partition(partMap))=>valfilterExpr=partMap.map{case(k,v)=>s"$k='$v'"}.mkString(" AND ")valdf=spark.table(tableName).filter(filterExpr)// ... process
caseNone=>}}
Pros:
Handles complex view scenarios
Per-table partition tracking
Filter support for region/geo
Cons:
Most complex strategy
Requires view parsing
Multiple API calls (one per table)
Which strategy should you use?
flowchart TD
A[Choose Strategy] --> B{Already have<br/>DataFrame loaded?}
B -->|Yes| C{Simple<br/>partitions?}
B -->|No| D{Need file paths<br/>or partitions?}
C -->|Yes, date-only| E[Strategy 1:<br/>DataFrame-based]
C -->|No, nested| F[Strategy 2:<br/>GCS Multi-threaded]
D -->|Partitions| G{Nested<br/>partitions?}
D -->|File paths| H[Strategy 4:<br/>File-level GCS]
D -->|Just bucket| I[Strategy 3:<br/>Bucket-level]
G -->|Yes| F
G -->|No| J{Working with<br/>views?}
J -->|Yes| K[Strategy 5:<br/>View-level]
J -->|No| E
style E fill:#87CEEB,stroke:#0066cc,color:#000
style F fill:#90EE90,stroke:#2d7a2d,color:#000
style H fill:#FFA500,stroke:#cc6600,color:#000
style I fill:#FFD700,stroke:#cc8800,color:#000
style K fill:#FF69B4,stroke:#cc0066,color:#000
defsmartAffectedPartitions(tableName:String,since:LocalDateTime):Array[Option[Partition]]={valspark=SparkSession.getActiveSession.get// Try Strategy 1 first (fastest if table already loaded)
valtryDf=Try(spark.table(tableName))tryDfmatch{caseSuccess(df)=>// Check if simple partitioning
valpartitions=df.schema.filter(_.name.contains("date")).map(_.name)if(partitions.length==1){// Use DataFrame-based (Strategy 1)
valdates=df.getAffectedPartitionDates(since)dates.map{case(date,_)=>Some(Partition(Map("date"->date.toString)))}}else{// Fallback to GCS multi-threaded (Strategy 2)
getAffectedPartitions(tableName,since)}caseFailure(_)=>// Table not readable, use GCS (Strategy 2)
getAffectedPartitions(tableName,since)}}
Part 9 - Real production patterns: What I learned shipping this
Pattern 1: Composition over configuration
Bad:
1
2
3
defprocessData(input:String,output:String,mode:String,format:String,partitions:Int):Unit={// 10 parameters, each adds complexity
}
Good:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
caseclassPipelineConfig(input:String,output:String,mode:SaveMode,format:String,partitions:Int)defprocessData(config:PipelineConfig):Either[String, Unit]={trySafely(unsafeCodeBlock={valdf=spark.read.format(config.format).load(config.input)// ... transform
df.write.mode(config.mode).save(config.output)},errorMessage=Some(s"Pipeline failed for config: $config"),exceptionHandlingCodeblock=Some(Left(s"Failed: $config")))match{caseRight(_)=>Right(())caseLeft(err)=>Left(err)}}
Pattern 2: Always use trySafely for I/O
1
2
3
4
5
6
7
8
9
10
11
// Reading from external source
valsourceDf=trySafely(spark.read.parquet("gs://source-bucket/data/"),Some("Failed to read source"),Some(spark.emptyDataFrame))match{caseRight(df)=>dfcaseLeft(empty)=>logger.error("Source read failed, using empty DataFrame")empty}
Pattern 3: CDC with audit trail
1
2
3
4
5
6
7
8
9
10
11
12
valdeltaDf=sourceDf.performDelta(previousLoadedDataset=Some(targetDf),primaryKeys=List("id"),columnsExcludeHashcodeCalculation=Some(List("updated_at")))// Write delta summary to audit table
valdeltaAudit=deltaDf.groupBy("delta_flag").count().withColumn("pipeline_run_ts",current_timestamp()).withColumn("table_name",lit("customers"))deltaAudit.write.mode("append").saveAsTable("audit.cdc_summary")