501 lines
13 KiB
Markdown
501 lines
13 KiB
Markdown
# Incremental Loading Strategies
|
|
|
|
Complete guide for configuring incremental data loading from external databases.
|
|
|
|
## Overview
|
|
|
|
Incremental loading imports only new or changed records instead of the entire dataset on each run. This is essential for recurring pipelines to minimize data transfer and processing time.
|
|
|
|
## Identify Watermark Column
|
|
|
|
A watermark column tracks which records have been loaded. The Glue job queries for records where watermark > last_loaded_value.
|
|
|
|
### Common Watermark Patterns
|
|
|
|
**Timestamp column** (preferred):
|
|
|
|
- `updated_at`, `modified_date`, `last_changed`, `etl_timestamp`
|
|
- Query: `WHERE timestamp_col > '2024-03-12 10:30:00'`
|
|
- Best for: Mutable data that gets updated
|
|
|
|
**Monotonic ID column**:
|
|
|
|
- `id`, `order_id`, `transaction_id` (auto-incrementing)
|
|
- Query: `WHERE id > 1234567`
|
|
- Best for: Immutable data with sequential IDs
|
|
|
|
**Both timestamp and ID**:
|
|
|
|
- Use timestamp for recent changes, ID as fallback for historical data
|
|
- Query: `WHERE timestamp_col > '...' OR (timestamp_col IS NULL AND id > ...)`
|
|
|
|
### Ask the User
|
|
|
|
Present candidates from the source schema:
|
|
|
|
```
|
|
I found these potential watermark columns:
|
|
1. CREATED_DATE (TIMESTAMP) - Never changes once set
|
|
2. UPDATED_AT (TIMESTAMP) - Updates when record changes (recommended)
|
|
3. ID (NUMBER) - Auto-incrementing primary key
|
|
|
|
Which should I use to track new/updated records?
|
|
```
|
|
|
|
**Recommendation logic**:
|
|
|
|
- If `updated_at` or `modified_date` exists → Recommend this (captures updates)
|
|
- Else if timestamp column exists → Use creation timestamp
|
|
- Else if auto-incrementing ID → Use ID
|
|
- Else → Recommend full refresh
|
|
|
|
## Determine Load Strategy
|
|
|
|
### Incremental Append (New Records Only)
|
|
|
|
**Best for**: Immutable data
|
|
|
|
- Transaction logs
|
|
- Event streams
|
|
- Historical orders
|
|
- Audit trails
|
|
|
|
**How it works**:
|
|
|
|
1. Query source for records where `watermark > last_watermark`
|
|
2. Append new records to target table
|
|
3. Update watermark to max value from current batch
|
|
|
|
**Pros**: Simple, fast, no deduplication needed
|
|
**Cons**: Doesn't capture updates to existing records
|
|
|
|
**PySpark example**:
|
|
|
|
```python
|
|
# Filter for new records
|
|
new_records_df = source_df.filter(
|
|
f"{watermark_column} > '{last_watermark}'"
|
|
)
|
|
|
|
# Append to target
|
|
new_records_df.writeTo(target_table).append()
|
|
```
|
|
|
|
### Incremental Upsert (New + Updated Records)
|
|
|
|
**Best for**: Mutable data
|
|
|
|
- Customer profiles
|
|
- Product catalogs
|
|
- Employee records
|
|
- Account balances
|
|
|
|
**How it works**:
|
|
|
|
1. Query source for records where `watermark > last_watermark`
|
|
2. Merge into target table using primary key
|
|
3. Update existing records, insert new ones
|
|
4. Update watermark
|
|
|
|
**Pros**: Captures both new records and updates
|
|
**Cons**: More complex, requires MERGE operation
|
|
|
|
**PySpark example**:
|
|
|
|
```python
|
|
# Get new/updated records
|
|
changed_records_df = source_df.filter(
|
|
f"{watermark_column} > '{last_watermark}'"
|
|
)
|
|
|
|
# Merge into target (upsert)
|
|
spark.sql(f"""
|
|
MERGE INTO {target_table} AS target
|
|
USING changed_records AS source
|
|
ON target.{primary_key} = source.{primary_key}
|
|
WHEN MATCHED THEN UPDATE SET *
|
|
WHEN NOT MATCHED THEN INSERT *
|
|
""")
|
|
```
|
|
|
|
### Full Refresh
|
|
|
|
**Best for**:
|
|
|
|
- Small dimension tables (< 10K rows)
|
|
- Data without watermark columns
|
|
- When source doesn't support incremental queries
|
|
|
|
**How it works**:
|
|
|
|
1. Truncate or drop target table
|
|
2. Load all records from source
|
|
3. No watermark needed
|
|
|
|
**Pros**: Simple, guarantees data consistency
|
|
**Cons**: Inefficient for large tables, higher data transfer costs
|
|
|
|
**PySpark example**:
|
|
|
|
```python
|
|
# Read all records
|
|
all_records_df = source_df.select("*")
|
|
|
|
# Overwrite target table
|
|
all_records_df.writeTo(target_table).overwritePartitions()
|
|
```
|
|
|
|
## Watermark Storage Options
|
|
|
|
The Glue job needs to persist the last loaded watermark value between runs.
|
|
|
|
### Option A: S3 File (Simple)
|
|
|
|
Store watermark in a text file in S3.
|
|
|
|
**Advantages**:
|
|
|
|
- Simple to implement
|
|
- No additional AWS services
|
|
- Easy to inspect and debug
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
import boto3
|
|
|
|
s3 = boto3.client('s3')
|
|
watermark_bucket = args['watermark_bucket']
|
|
watermark_key = args['watermark_key']
|
|
|
|
# Read last watermark
|
|
try:
|
|
obj = s3.get_object(Bucket=watermark_bucket, Key=watermark_key)
|
|
last_watermark = obj['Body'].read().decode('utf-8').strip()
|
|
print(f"Last watermark: {last_watermark}")
|
|
except s3.exceptions.NoSuchKey:
|
|
last_watermark = '1970-01-01 00:00:00' # Default for timestamp
|
|
# OR last_watermark = '0' # Default for ID
|
|
print("No previous watermark found, starting from beginning")
|
|
|
|
# After loading, update watermark
|
|
new_watermark = filtered_df.agg({watermark_column: "max"}).collect()[0][0]
|
|
s3.put_object(
|
|
Bucket=watermark_bucket,
|
|
Key=watermark_key,
|
|
Body=str(new_watermark)
|
|
)
|
|
print(f"Updated watermark to: {new_watermark}")
|
|
```
|
|
|
|
**S3 path structure**:
|
|
|
|
```
|
|
s3://my-glue-watermarks/
|
|
customers.txt → "2024-03-12 14:30:00"
|
|
orders.txt → "2024-03-12 14:25:00"
|
|
products.txt → "2024-03-10 08:00:00"
|
|
```
|
|
|
|
### Option B: DynamoDB Table (Robust)
|
|
|
|
Store watermarks in a DynamoDB table with one item per job.
|
|
|
|
**Advantages**:
|
|
|
|
- Atomic updates
|
|
- Query watermarks programmatically
|
|
- Can store additional metadata (last run time, row count, etc.)
|
|
|
|
**Create table**:
|
|
|
|
```bash
|
|
aws dynamodb create-table \
|
|
--table-name glue-job-watermarks \
|
|
--attribute-definitions \
|
|
AttributeName=job_name,AttributeType=S \
|
|
--key-schema \
|
|
AttributeName=job_name,KeyType=HASH \
|
|
--billing-mode PAY_PER_REQUEST \
|
|
--region <region>
|
|
```
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
import boto3
|
|
from datetime import datetime
|
|
|
|
dynamodb = boto3.resource('dynamodb')
|
|
table = dynamodb.Table('glue-job-watermarks')
|
|
job_name = args['JOB_NAME']
|
|
|
|
# Read last watermark
|
|
try:
|
|
response = table.get_item(Key={'job_name': job_name})
|
|
item = response['Item']
|
|
last_watermark = item['watermark']
|
|
print(f"Last watermark for {job_name}: {last_watermark}")
|
|
except KeyError:
|
|
last_watermark = '1970-01-01 00:00:00'
|
|
print("No previous watermark found, starting from beginning")
|
|
|
|
# After loading, update watermark
|
|
new_watermark = filtered_df.agg({watermark_column: "max"}).collect()[0][0]
|
|
table.put_item(Item={
|
|
'job_name': job_name,
|
|
'watermark': str(new_watermark),
|
|
'last_run_time': datetime.now().isoformat(),
|
|
'rows_loaded': row_count
|
|
})
|
|
print(f"Updated watermark to: {new_watermark}")
|
|
```
|
|
|
|
### Option C: Query Target Table (Advanced)
|
|
|
|
Query the target S3 Table to determine the max watermark value.
|
|
|
|
**Advantages**:
|
|
|
|
- No external storage needed
|
|
- Watermark always matches actual data
|
|
|
|
**Disadvantages**:
|
|
|
|
- Requires target table scan (can be slow)
|
|
- Doesn't work for first run (empty table)
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
# Query target table for max watermark
|
|
try:
|
|
max_watermark_df = spark.sql(f"""
|
|
SELECT MAX({watermark_column}) as max_value
|
|
FROM {target_table}
|
|
""")
|
|
last_watermark = max_watermark_df.collect()[0]['max_value']
|
|
if last_watermark is None:
|
|
last_watermark = '1970-01-01 00:00:00'
|
|
print(f"Max watermark in target: {last_watermark}")
|
|
except:
|
|
last_watermark = '1970-01-01 00:00:00'
|
|
print("Target table empty or doesn't exist, starting from beginning")
|
|
```
|
|
|
|
**Recommendation**: Use **Option A (S3 file)** for simplicity unless you have specific requirements for DynamoDB's features.
|
|
|
|
## Handling Edge Cases
|
|
|
|
### Timezone Considerations
|
|
|
|
**Problem**: Source database uses one timezone, target uses another
|
|
**Solution**: Normalize all timestamps to UTC
|
|
|
|
```python
|
|
from pyspark.sql.functions import to_utc_timestamp
|
|
|
|
# Convert source timestamp to UTC
|
|
df_utc = source_df.withColumn(
|
|
"timestamp_utc",
|
|
to_utc_timestamp(col("source_timestamp"), "America/New_York")
|
|
)
|
|
```
|
|
|
|
### Backfill Historical Data
|
|
|
|
**Scenario**: Need to load historical data before starting incremental loads
|
|
|
|
**Approach**:
|
|
|
|
1. Set watermark to earliest desired date: `1900-01-01 00:00:00`
|
|
2. Run job once to load all historical data
|
|
3. Subsequent runs will be incremental from that point forward
|
|
|
|
**OR** load in batches:
|
|
|
|
```python
|
|
# Batch 1: Load 2020 data
|
|
WHERE timestamp >= '2020-01-01' AND timestamp < '2021-01-01'
|
|
|
|
# Batch 2: Load 2021 data
|
|
WHERE timestamp >= '2021-01-01' AND timestamp < '2022-01-01'
|
|
|
|
# Batch 3: Load 2022+ data
|
|
WHERE timestamp >= '2022-01-01'
|
|
|
|
# Then switch to incremental
|
|
```
|
|
|
|
### Late-Arriving Data
|
|
|
|
**Problem**: Records arrive after their timestamp (e.g., event from yesterday arrives today)
|
|
|
|
**Solution 1**: Add buffer window
|
|
|
|
```python
|
|
# Load data from 1 day before last watermark to catch late arrivals
|
|
buffer_watermark = last_watermark - timedelta(days=1)
|
|
WHERE timestamp > buffer_watermark
|
|
```
|
|
|
|
**Solution 2**: Use separate updated_at column
|
|
|
|
```python
|
|
# Use updated_at instead of event_timestamp
|
|
WHERE updated_at > last_watermark
|
|
```
|
|
|
|
### Deleted Records
|
|
|
|
**Problem**: Source deletes records, but incremental load doesn't capture deletions
|
|
|
|
**Solutions**:
|
|
|
|
**Option 1**: Periodic full refresh
|
|
|
|
- Run incremental loads daily
|
|
- Run full refresh weekly to remove deleted records
|
|
|
|
**Option 2**: Soft deletes
|
|
|
|
- Source system marks records as deleted instead of removing them
|
|
- Filter: `WHERE updated_at > last_watermark OR deleted_at > last_watermark`
|
|
|
|
**Option 3**: Compare and prune
|
|
|
|
- Periodically query source for all IDs
|
|
- Find IDs in target that don't exist in source
|
|
- Delete those records from target
|
|
|
|
### Duplicate Records
|
|
|
|
**Problem**: Same record loaded multiple times due to job retries or watermark issues
|
|
|
|
**Prevention**:
|
|
|
|
1. Use upsert instead of append for mutable data
|
|
2. Add deduplication logic:
|
|
|
|
```python
|
|
from pyspark.sql.window import Window
|
|
from pyspark.sql.functions import row_number
|
|
|
|
# Deduplicate by primary key, keeping latest by watermark
|
|
window = Window.partitionBy("primary_key").orderBy(col(watermark_column).desc())
|
|
deduplicated_df = df.withColumn("row_num", row_number().over(window)) \
|
|
.filter(col("row_num") == 1) \
|
|
.drop("row_num")
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Index Watermark Column
|
|
|
|
Ensure the watermark column has an index in the source database:
|
|
|
|
```sql
|
|
-- Oracle
|
|
CREATE INDEX idx_customers_updated_at ON CUSTOMERS(UPDATED_AT);
|
|
|
|
-- SQL Server
|
|
CREATE INDEX idx_customers_updated_at ON CUSTOMERS(UPDATED_AT);
|
|
|
|
-- PostgreSQL
|
|
CREATE INDEX idx_customers_updated_at ON customers(updated_at);
|
|
```
|
|
|
|
Without an index, source database will do full table scans.
|
|
|
|
### Batch Size Tuning
|
|
|
|
For high-volume tables, load data in smaller batches:
|
|
|
|
```python
|
|
# Load 1 hour of data at a time
|
|
batch_size = timedelta(hours=1)
|
|
current_watermark = last_watermark
|
|
|
|
while current_watermark < datetime.now():
|
|
next_watermark = current_watermark + batch_size
|
|
|
|
batch_df = source_df.filter(
|
|
(col(watermark_column) > current_watermark) &
|
|
(col(watermark_column) <= next_watermark)
|
|
)
|
|
|
|
batch_df.writeTo(target_table).append()
|
|
|
|
current_watermark = next_watermark
|
|
```
|
|
|
|
### Parallel Reads
|
|
|
|
Use Spark's partitioning for parallel reads from source:
|
|
|
|
```python
|
|
source_df = spark.read.format("jdbc").options(
|
|
url=jdbc_url,
|
|
dbtable=table_name,
|
|
numPartitions=10, # Read in parallel with 10 partitions
|
|
partitionColumn=watermark_column,
|
|
lowerBound=last_watermark,
|
|
upperBound=current_time
|
|
).load()
|
|
```
|
|
|
|
## Monitoring and Alerting
|
|
|
|
Track these metrics for each incremental load:
|
|
|
|
- **Rows loaded**: Number of new/updated records
|
|
- **Watermark advancement**: How much watermark advanced
|
|
- **Load duration**: Time taken for the job
|
|
- **Data lag**: Difference between source max watermark and loaded watermark
|
|
|
|
```python
|
|
# Log metrics
|
|
print(f"Job metrics:")
|
|
print(f" Rows loaded: {row_count}")
|
|
print(f" Previous watermark: {last_watermark}")
|
|
print(f" New watermark: {new_watermark}")
|
|
print(f" Watermark advancement: {new_watermark - last_watermark}")
|
|
print(f" Load duration: {load_duration} seconds")
|
|
|
|
# Publish to CloudWatch (optional)
|
|
cloudwatch = boto3.client('cloudwatch')
|
|
cloudwatch.put_metric_data(
|
|
Namespace='GlueJobs',
|
|
MetricData=[{
|
|
'MetricName': 'RowsLoaded',
|
|
'Value': row_count,
|
|
'Unit': 'Count',
|
|
'Dimensions': [{'Name': 'JobName', 'Value': job_name}]
|
|
}]
|
|
)
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Choose the right watermark column**: Prefer `updated_at` over `created_at` for mutable data
|
|
2. **Test with small batches first**: Verify logic before full-scale loads
|
|
3. **Add buffer for late arrivals**: Consider loading data from 1 day before watermark
|
|
4. **Monitor watermark advancement**: Alert if watermark stops advancing
|
|
5. **Handle timezones consistently**: Convert all timestamps to UTC
|
|
6. **Index watermark column in source**: Dramatically improves query performance
|
|
7. **Use upsert for mutable data**: Prevents duplicates and captures updates
|
|
8. **Store watermark reliably**: S3 file is simple and sufficient for most cases
|
|
|
|
## Summary
|
|
|
|
Incremental loading workflow:
|
|
|
|
1. **Identify watermark column** - Timestamp or auto-incrementing ID
|
|
2. **Choose load strategy** - Append (immutable) vs Upsert (mutable) vs Full Refresh
|
|
3. **Store watermark** - S3 file, DynamoDB, or query target table
|
|
4. **Handle edge cases** - Timezones, late arrivals, deletions, duplicates
|
|
5. **Optimize performance** - Index watermark, batch loading, parallel reads
|
|
6. **Monitor** - Track rows loaded, watermark advancement, data lag
|
|
|
|
With proper incremental loading, recurring pipelines efficiently sync only changed data from external databases.
|