Files

157 lines
5.7 KiB
Python
Raw Permalink Normal View History

"""
DuckDB Iceberg Transformation Operator for MERICA Data Lakehouse Studio
Executes in-process SQL transformations from Bronze Parquet to Silver Iceberg tables.
Applies Lazy Import Guardrail to prevent memory bloat on DAG Processor.
"""
import os
import logging
from typing import Any, Dict, List, Optional, Sequence
from helpers.base_lakehouse_operator import BaseLakehouseOperator
try:
from airflow.sdk.definitions.context import Context
except ImportError:
try:
from airflow.utils.context import Context
except ImportError:
class Context(dict):
pass
logger = logging.getLogger(__name__)
class DuckDBIcebergOperator(BaseLakehouseOperator):
"""
DuckDB-powered Lakehouse Operator.
Transforms Bronze Parquet datasets to clean Silver Iceberg/Parquet tables
with high-throughput, low-memory in-process SQL execution.
"""
template_fields: Sequence[str] = (
"source_parquet_path",
"target_table_name",
"transformation_sql"
)
def __init__(
self,
*,
source_parquet_path: str,
target_table_name: str,
transformation_sql: Optional[str] = None,
primary_keys: Optional[List[str]] = None,
order_by_col: Optional[str] = "updated_at",
s3_endpoint: Optional[str] = None,
s3_access_key: Optional[str] = None,
s3_secret_key: Optional[str] = None,
s3_bucket: Optional[str] = "dlh-silver",
**kwargs
):
inlets = kwargs.pop("inlets", [source_parquet_path])
clean_target = target_table_name if "." in target_table_name else f"silver.{target_table_name}"
outlets = kwargs.pop("outlets", [clean_target])
super().__init__(
layer="silver",
engine="duckdb",
inlets=inlets,
outlets=outlets,
**kwargs
)
self.source_parquet_path = source_parquet_path
self.target_table_name = target_table_name
self.primary_keys = primary_keys or ["id"]
self.order_by_col = order_by_col
if not transformation_sql:
pk_cols = ", ".join(self.primary_keys)
order_col = self.order_by_col or self.primary_keys[0]
read_func = "read_json_auto" if self.source_parquet_path.lower().endswith(".json") else "read_parquet"
self.transformation_sql = f"""
WITH raw_source AS (
SELECT * FROM {read_func}('{self.source_parquet_path}')
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY {pk_cols} ORDER BY {order_col} DESC) as _row_num
FROM raw_source
)
SELECT * EXCLUDE (_row_num)
FROM ranked
WHERE _row_num = 1
""".strip()
else:
self.transformation_sql = transformation_sql
self.s3_endpoint = s3_endpoint or os.getenv("MINIO_ENDPOINT", "minio:9000")
self.s3_access_key = s3_access_key or os.getenv("MINIO_ROOT_USER", "minio_admin")
self.s3_secret_key = s3_secret_key or os.getenv("MINIO_ROOT_PASSWORD", "minio_password")
self.s3_bucket = s3_bucket or "dlh-silver"
def execute(self, context: Context) -> Dict[str, Any]:
"""
Execute DuckDB SQL pipeline.
Lazy imports duckdb inside execute() to respect ADR-03 & avoid scheduler OOM.
"""
logger.info(
"Starting DuckDB Iceberg transformation: %s -> %s",
self.source_parquet_path, self.target_table_name
)
# Lazy Import Guardrail
try:
import duckdb
except ImportError as e:
logger.error("DuckDB is not installed in the runtime environment: %s", e)
raise
con = duckdb.connect(database=":memory:")
try:
# Configure MinIO / S3 connectivity in DuckDB
clean_endpoint = self.s3_endpoint.replace("http://", "").replace("https://", "")
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute(f"SET s3_endpoint = '{clean_endpoint}';")
con.execute(f"SET s3_access_key_id = '{self.s3_access_key}';")
con.execute(f"SET s3_secret_access_key = '{self.s3_secret_key}';")
con.execute("SET s3_use_ssl = false;")
con.execute("SET s3_url_style = 'path';")
con.execute("SET s3_region = 'us-east-1';")
# Resolve query
target_s3_path = f"s3://{self.s3_bucket}/{self.target_table_name}/data.parquet"
if self.transformation_sql.strip().upper().startswith("COPY"):
query = self.transformation_sql
else:
query = f"""
COPY (
{self.transformation_sql}
) TO '{target_s3_path}' (FORMAT PARQUET, COMPRESSION ZSTD);
"""
logger.info("Executing DuckDB Query:\n%s", query)
con.execute(query)
# Get row count
count_res = con.execute(f"SELECT count(*) FROM read_parquet('{target_s3_path}')").fetchone()
rows_written = count_res[0] if count_res else 0
logger.info(
"✅ DuckDB Transformation completed successfully! %d rows written to %s",
rows_written, target_s3_path
)
return {
"status": "success",
"engine": "duckdb",
"target_table": self.target_table_name,
"target_path": target_s3_path,
"rows_processed": rows_written
}
except Exception as e:
logger.error("DuckDB Transformation Failed: %s", e)
raise
finally:
con.close()