ADR 029 — S3 Lifecycle Management: Archive and Errored Routing¶
Status: Proposed Date: 2026-06-01
Context¶
The S3 pipeline writes every file twice into a single bucket (S3_BASE_BUCKET) under two prefixes (referred to as "buckets" in config, though they are prefix strings within one physical S3 bucket):
ingest-bucket/{domain}/{report}/{mode}/{date}/— raw bytes from the source, original column names.stage-bucket/{domain}/{report}/{mode}/{date}/— a header-sanitized copy, produced deterministically from the ingest file bystandardize_headers()(stage_tasks.py:199-220), read by SnowflakeCOPY INTO.
Two problems have emerged:
- Both prefixes grow unbounded. There is no
delete_object, lifecycle rule, or retention logic anywhere in the codebase.ingest-bucket/andstage-bucket/accumulate indefinitely and are starting to get large. - No outcome routing after load. When
COPY INTOsucceeds or fails, the ingest file stays iningest-bucket/with no signal about what happened to it. There is no way to tell which raw files made it into Bronze and which did not.
An archive-bucket/ prefix already exists in the bucket for this purpose but is not yet wired into the pipeline.
Decision¶
Route the ingest file to one of two outcome prefixes after COPY INTO completes, making ingest-bucket/ a short-lived transit zone rather than permanent storage.
| Prefix | Role | Lifecycle |
|---|---|---|
ingest-bucket/ |
Transit zone — raw file lands here, moves out on load outcome | Expire 7 days (safety net only) |
stage-bucket/ |
Transient — derived from ingest, read by COPY INTO, no replay value |
Expire 14 days |
archive-bucket/ |
Successfully loaded — ingest file moved here after a successful COPY INTO |
Glacier 90 days; expire 730 days |
errored-bucket/ |
Failed load — ingest file moved here after a COPY INTO failure |
Expire 30 days |
The invariant: a file in archive-bucket/ means it loaded into Bronze successfully. A file in errored-bucket/ means the load failed. A file still in ingest-bucket/ means the pipeline has not completed (or the move itself failed — cleaned by the 7-day lifecycle).
How archive-bucket/ and errored-bucket/ map to the existing prefix naming pattern¶
The existing prefixes (ingest-bucket, stage-bucket) use a {name}-bucket suffix convention in the config schema (dag_config_v2.py:148-149) and path_builder.py. The two new prefixes follow the same convention: archive-bucket and errored-bucket.
Code changes¶
ingest_keys are currently available in stage_result (produced by stage_tasks.py:118) but are not threaded into snowflake_data by build_copy_parameters(). Two changes are required:
-
build_copy_parameters()(snowflake_tasks.py:346): passingest_keysfromstage_resultthrough into the returned dict socopy_to_snowflakehas access to them. -
copy_to_snowflake()(snowflake_tasks.py:428): - On success: move (copy + delete) ingest file(s) to
archive-bucket/ -
On failure (existing handler
855-872): move ingest file(s) toerrored-bucket/, then rollback, then re-raise -
path_builder.py: addbuild_archive_path()andbuild_errored_path()alongside the existingbuild_ingest_path()/build_stage_path(), usingbucket_prefix="archive-bucket"andbucket_prefix="errored-bucket"respectively. Theerrored-bucket/layout drops the{mode}segment — quarantine is for inspection, not reprocessing.
Move semantics: copy then delete¶
S3 has no atomic rename. The move is implemented as copy + delete:
- If the copy succeeds and delete fails, the file exists in both places. The 7-day ingest-bucket/ lifecycle cleans the duplicate automatically — no data loss.
- Both moves (archive and errored) are best-effort: a move error is logged as a warning but does not replace the original load error or success. The task outcome is never changed by a move failure.
S3 lifecycle rules (declarative, no application code)¶
Configured on S3_BASE_BUCKET per prefix (AWS Console or CLI):
ingest-bucket/ → Expiration: 7 days
stage-bucket/ → Expiration: 14 days
archive-bucket/ → Transition to GLACIER: 90 days; Expiration: 730 days
errored-bucket/ → Expiration: 30 days
Consequences¶
Positive¶
- Clear outcome signal:
archive-bucket/= loaded,errored-bucket/= failed, nothing ambiguous. ingest-bucket/andstage-bucket/stop growing unbounded.archive-bucket/enables replay of raw source data for up to 2 years after initial load.- Failed loads preserve an inspectable artifact instead of vanishing when
stage-bucket/expires. - The 7-day
ingest-bucket/safety net handles any move failure without manual intervention.
Negative / trade-offs¶
- A Bronze table rebuild from data older than 2 years can no longer replay from S3 (acceptable — Bronze retains history via
batch_replace, ADR 011). build_copy_parameters()must threadingest_keysthrough — a small but required interface change.- The copy + delete on the success path adds two S3 API calls per file on every successful load.
- Lifecycle rules live outside the repo (AWS Console / infra layer) and must be kept in sync with this ADR.
Options Considered¶
ingest-bucket/as long-term archive (original design) — rejected after discoveringarchive-bucket/already exists and is semantically cleaner. Having a prefix explicitly named "archive" for successfully-loaded files is more meaningful than retaining everything in "ingest."- Lifecycle only, no archive routing — rejected. Lifecycle stops the growth but does not distinguish successful from failed loads;
ingest-bucket/would just silently expire for both outcomes. - Quarantine from
stage-bucket/instead ofingest-bucket/— rejected.stage-bucket/is derived (standardized headers), so it's a less faithful representation of the source for debugging. Moving the original ingest file is more useful for incident investigation. - Separate physical S3 buckets per prefix — rejected. Per-prefix lifecycle rules on one bucket serve the same purpose with less IAM and config surface.
- In-code deletion of
stage-bucket/after successful load — not needed. The 14-day lifecycle handles it without coupling cleanup to the happy path.
See also: ADR 009 (S3 path conventions) | ADR 011 (Bronze retains history via batch_replace) | path_builder.py | stage_tasks.py | snowflake_tasks.py