ADR 006: Bronze TRUNCATE + FORCE Decision¶
Status: Accepted Date: 2025-12-24 Decision Makers: Data Engineering Team Related: ADR 001 (Load Strategy Terminology), dbt Phase 1 Implementation
Context¶
When implementing Bronze landing tables for the xo-data ELT pipeline, we faced a critical decision about how to handle data loading to ensure idempotent pipeline behavior.
Problem Statement¶
- Snowflake Copy History Limitation (14 Days):
- Snowflake Standard Edition only retains COPY history for 14 days
- After 14 days,
FORCE=FALSEprovides no protection against duplicate loads -
Pipelines need to be rerunnable indefinitely (not just within 14 days)
-
DAG Reruns Must Be Idempotent:
- Running a DAG twice for the same
data_dateshould produce identical results - Current behavior: APPEND mode causes duplicate data on reruns
-
Requirement: Rerunning
2025-12-15data should replace previous load, not append -
Bronze Tables Are Temporary:
- Bronze is a staging/landing layer, not permanent storage
- Historical data is preserved in Silver layer (dbt transformations)
- Bronze should be truncated and reloaded daily
Initial Concerns¶
- Data Loss: What if TRUNCATE succeeds but COPY fails?
- Concurrent Queries: What if analysts are querying the table during TRUNCATE?
- Error Scenarios: How to handle partial loads or malformed data?
Decision¶
Always TRUNCATE Bronze tables before loading, with FORCE=TRUE, wrapped in a transaction.
Implementation Pattern¶
BEGIN TRANSACTION;
TRUNCATE TABLE {bronze_table};
COPY INTO {bronze_table} FROM @stage
FORCE = TRUE
ON_ERROR = 'ABORT_STATEMENT'
;
COMMIT;
Key Components¶
- TRUNCATE TABLE: Empties table while preserving structure (metadata operation, instant)
- FORCE=TRUE: Bypasses copy history to allow reloading same files
- ON_ERROR='ABORT_STATEMENT': Triggers ROLLBACK on any COPY failure
- Transaction Wrapper: Ensures atomic TRUNCATE + COPY (all-or-nothing)
Architectural Clarification¶
Critical Distinction:
- Bronze (xo-foundry): ALWAYS truncated, regardless of refresh type
- Silver (dbt): Refresh type (full_refresh, incremental, historical) affects merge logic, not Bronze loading
Load strategies ONLY affect: 1. S3 path structure (already implemented) 2. Silver merge logic (handled by dbt, not xo-foundry)
Rationale¶
Why TRUNCATE?¶
- Idempotency: Guarantees same result on DAG reruns
- Simplicity: No complex conditional logic needed
- Performance: TRUNCATE is a metadata operation (instant)
- Safety: Preserves table structure, grants, and Time Travel
- Bronze Purpose: Landing tables are temporary by design
Why FORCE=TRUE?¶
- 14-Day Limitation: Snowflake Standard only tracks copy history for 14 days
- Rerunnability: Must support reruns after 14 days
- Simplicity: No need to track what files were loaded when
- Bronze Pattern: Combined with TRUNCATE, ensures clean slate
Why ON_ERROR='ABORT_STATEMENT'?¶
- Transaction Safety: Triggers ROLLBACK on any error
- Data Integrity: Prevents partial loads (all-or-nothing)
- Clear Failures: Airflow task fails immediately on error
Why Transaction Wrapper?¶
- Atomicity: TRUNCATE + COPY execute as single unit
- Rollback Protection: TRUNCATE is reverted if COPY fails
- Data Loss Prevention: Table restored to pre-TRUNCATE state on error
Consequences¶
Positive¶
- ✅ Idempotent Pipelines: Rerunning DAGs produces identical results
- ✅ No 14-Day Limitation: Reruns work indefinitely
- ✅ Transaction Safety: ROLLBACK protects against data loss
- ✅ Simple Logic: No conditional behavior based on refresh type
- ✅ Clean Separation: Bronze (xo-foundry) vs Silver (dbt) responsibilities clear
- ✅ Best Practice: Aligns with industry standard ELT patterns
Negative¶
- ⚠️ Concurrent Query Risk: Queries fail if run during TRUNCATE window
- ⚠️ Window of Unavailability: Brief period when table is empty (1-2 minutes)
- ⚠️ No Partial Loads: All-or-nothing approach (failure reverts everything)
Mitigations¶
| Risk | Mitigation |
|---|---|
| Concurrent query failures | Schedule during low-traffic windows (early morning) |
| Data loss on failure | Transaction ROLLBACK restores pre-TRUNCATE state |
| Partial load scenarios | ON_ERROR='ABORT_STATEMENT' ensures atomicity |
| Debugging complexity | Enhanced logging shows TRUNCATE, COPY, COMMIT steps clearly |
Neutral¶
- 🔄 Silver Responsibility: Historical data preserved in Silver layer
- 🔄 Time Travel: Snowflake Time Travel provides recovery (1-90 days)
- 🔄 Monitoring: Existing Airflow alerts detect load failures
Implementation¶
Files Modified¶
packages/xo-foundry/src/xo_foundry/tasks/snowflake_tasks.py(lines 217-460):- Changed
FORCE=FALSE→FORCE=TRUE(hardcoded) - Changed
ON_ERROR='SKIP_FILE'→ON_ERROR='ABORT_STATEMENT'(hardcoded) - Added
BEGIN TRANSACTIONbefore TRUNCATE - Added
TRUNCATE TABLEexecution - Added
COMMITafter successful COPY -
Added
ROLLBACKin exception handler -
packages/xo-foundry/src/xo_foundry/dag_factory/templates/snowflake_load.py.j2(line 127): - Changed
"autocommit": True→"autocommit": False - Enables explicit transaction control
Removed Configuration Options¶
The following config options were removed (behavior is now hardcoded):
- force_overwrite: Always TRUE
- on_error config: Always 'ABORT_STATEMENT'
- Conditional TRUNCATE logic: Always executed
Load Strategy Behavior (Unchanged)¶
The load_strategy field in YAML configs remains unchanged and is used ONLY for:
1. S3 path structure (already working)
2. Silver merge logic (dbt, future implementation)
3. Documentation/clarity
Bronze loading is now strategy-agnostic - always TRUNCATE + FORCE for all strategies.
Testing Strategy¶
Manual Testing Checklist¶
- Verify TRUNCATE execution in logs
- Test idempotency: run DAG twice for same date, check row counts match
- Test transaction rollback: inject malformed CSV, verify table restored
- Verify FORCE=TRUE in Snowflake query history
- Test Airflow task failure detection on COPY errors
Validation Queries¶
-- Check TRUNCATE execution in query history
SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE QUERY_TEXT ILIKE '%TRUNCATE TABLE%'
AND START_TIME > CURRENT_TIMESTAMP - INTERVAL '1 day'
ORDER BY START_TIME DESC;
-- Verify idempotency: all batches have same row count
SELECT BATCH_ID, COUNT(*) as row_count
FROM WBP_DB_DEV.BRONZE.GLADLY_CONTACT_TIMESTAMPS
GROUP BY BATCH_ID
HAVING COUNT(*) != (
SELECT COUNT(*)
FROM WBP_DB_DEV.BRONZE.GLADLY_CONTACT_TIMESTAMPS
WHERE BATCH_ID = '2025-12-24'
LIMIT 1
);
-- Should return 0 rows (all batches identical)
Deployment¶
Rollout Plan¶
- Phase 1: Deploy to dev environment, monitor for 1 day
- Phase 2: Validate idempotency with multiple test runs
- Phase 3: Deploy to production with team communication
- Phase 4: Monitor first production run closely
Communication¶
Team should be informed: - Bronze tables will be truncated before each load - Brief window (~1-2 min) when tables are empty - Queries during load window will fail (schedule accordingly) - Silver tables maintain historical data (Bronze is temporary)
Related Decisions¶
- ADR 001: Defines
full_refresh,incremental,historicalterminology - ADR 004: Metadata columns and RECORD_KEY/RECORD_HASH usage
- dbt Phase 1: Silver layer preserves historical data from Bronze
Future Considerations¶
Phase 2: Silver FORCE Option¶
Separate from this decision, we may implement a --force flag for Silver layer:
- Purpose: UPDATE/REPLACE records by RECORD_KEY regardless of existence
- Scope: Affects dbt models, not xo-foundry Bronze loading
- Distinction: Bronze ALWAYS truncates; Silver --force is optional override
Monitoring Enhancements¶
Consider adding: - Snowflake query monitoring to detect concurrent queries during TRUNCATE - Automated alerts for ROLLBACK events - Dashboard showing Bronze load times and transaction durations
References¶
- Snowflake Documentation: TRUNCATE vs DELETE vs DROP
- Snowflake COPY INTO: FORCE parameter behavior
- Snowflake Transactions: BEGIN/COMMIT/ROLLBACK semantics
- Implementation Plan:
.claude/plans/fluffy-soaring-barto.md
Approval¶
Approved: 2025-12-24 Implemented: 2025-12-24 Validated: Pending production deployment
Summary: Bronze tables are ALWAYS truncated before loading with FORCE=TRUE and transaction safety, ensuring idempotent pipelines and clean separation between Bronze (temporary) and Silver (historical) layers.