157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""
|
|||
|
|
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()
|