Medallion Layers¶
block-beta
columns 4
block:bronze:1
BH["Bronze"]
BL1["Raw CDC events"]
BL2["No transforms"]
BL3["90-day snapshots"]
BL4["SLA: ≤ 30s"]
BL5["Consumer: Flink only"]
end
space
block:silver:1
SH["Silver"]
SL1["Envelope unwrapped"]
SL2["Deduped · Validated"]
SL3["1-year snapshots"]
SL4["SLA: ≤ 2 min"]
SL5["Consumer: Gold Flink · dbt"]
end
space
block:gold:1
GH["Gold"]
GL1["Business aggregations"]
GL2["Stable public schema"]
GL3["2-year snapshots"]
GL4["SLA: ≤ 5 min"]
GL5["Consumer: Analysts · ML"]
end
bronze --> silver
silver --> gold
classDef bronze fill:#92400e,color:#fff,stroke:#92400e
classDef silver fill:#1e3a5f,color:#fff,stroke:#1e3a5f
classDef gold fill:#14532d,color:#fff,stroke:#14532d
class BH,BL1,BL2,BL3,BL4,BL5 bronze
class SH,SL1,SL2,SL3,SL4,SL5 silver
class GH,GL1,GL2,GL3,GL4,GL5 gold
Layer Contracts¶
Purpose: Complete audit record and Kappa reprocessing source.
Nothing is filtered, transformed, or deduplicated. If Debezium emitted it, Bronze stores it — including heartbeat events, schema evolution tombstones, and operational metadata fields. Bronze is the recovery surface: if a Silver job has a bug, Silver is rebuilt by replaying Bronze, not by re-reading PostgreSQL.
Purpose: Trustworthy, analyst-safe copy of source tables.
The Debezium before/after envelope is unwrapped — analysts see the current record, not the CDC wire format. Duplicate event_id values (from connector restarts) are suppressed by the 24-hour RocksDB dedup window. Malformed records are routed to DLQ with a structured failure reason; they never enter Silver silently.
Type coercions and null normalizations are applied at this layer. A field typed as string in Avro but containing a timestamp is coerced to timestamp_tz. These coercions are defined in the Silver Flink job, not in the Avro schema — schema evolution and coercion are independent concerns.
Purpose: Stable public data product interface.
Pre-computed business aggregations built by dbt incremental models. The Gold schema is declared in contracts/data-products/orders-analytics.yaml and is the stable interface that downstream consumers depend on. Schema changes require a contract update and PR review by the owning team — not just a dbt model change.
Gold tables are partitioned by business date (not ingestion date) because analysts filter by order date, not pipeline run date. The updated_at column is the freshness signal that Prometheus scrapes to evaluate the Gold SLA.
Schema Evolution Across Layers¶
Iceberg identifies columns by integer field ID, not by name. When a source column is renamed:
- The Avro schema gets an alias for the old name (backward-compatible)
- The Silver Flink job maps the alias to the new column name
- Existing Silver Parquet files continue to read correctly — no rewrite needed
- Gold dbt models reference the new name in SQL; old Gold partitions are unaffected
This means a column rename propagates from source to Gold without a migration job touching existing data files. The Iceberg field ID mapping absorbs the rename at the storage layer.
Reprocessing via Kappa¶
When a Silver job has a bug and corrupts a partition, recovery uses the Bronze layer as the source of truth:
1. Reset flink-silver-orders consumer group to affected Bronze offset
2. Submit fixed Flink job pointing to a new Silver Iceberg table branch
3. Validate Silver output against known-good records
4. Swap Gold dbt source ref to the new Silver table
5. Expire old Silver snapshots
The 90-day Bronze retention window (set by kafka_retention_days: 30 in the data product contract) is the maximum Kappa reprocessing window. Any corruption older than 90 days would require re-reading from PostgreSQL WAL archives.