|
| 1 | +"""Build the refined dim_visitors Delta table from validated Wistia events. |
| 2 | +
|
| 3 | +Required AWS Glue job arguments: |
| 4 | + --JOB_NAME |
| 5 | +
|
| 6 | +Input resolution order: |
| 7 | + 1. --INGESTION_RUN_ID and --VALIDATION_REPORT_URI |
| 8 | + 2. The same properties from the current Glue workflow run |
| 9 | +
|
| 10 | +Optional arguments: |
| 11 | + --INGESTION_RUN_ID |
| 12 | + --VALIDATION_REPORT_URI |
| 13 | + --REFINED_PREFIX Default: refined/dim_visitors |
| 14 | + --DIM_VISITORS_TABLE_URI Overrides the inferred Delta table URI |
| 15 | + --WORKFLOW_NAME Supplied by AWS Glue when run in a workflow |
| 16 | + --WORKFLOW_RUN_ID Supplied by AWS Glue when run in a workflow |
| 17 | +
|
| 18 | +Configure the Glue Spark job with: |
| 19 | + --datalake-formats delta |
| 20 | + --conf spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension |
| 21 | + --conf spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import json |
| 27 | +import logging |
| 28 | +import sys |
| 29 | +from dataclasses import dataclass |
| 30 | +from typing import Any |
| 31 | +from urllib.parse import urlparse |
| 32 | + |
| 33 | +import boto3 |
| 34 | +from awsglue.utils import getResolvedOptions |
| 35 | +from botocore.exceptions import BotoCoreError, ClientError |
| 36 | + |
| 37 | + |
| 38 | +LOGGER = logging.getLogger(__name__) |
| 39 | + |
| 40 | + |
| 41 | +class DimVisitorsError(RuntimeError): |
| 42 | + """Raised when dim_visitors cannot be built safely.""" |
| 43 | + |
| 44 | + |
| 45 | +@dataclass(frozen=True) |
| 46 | +class JobConfig: |
| 47 | + job_name: str |
| 48 | + ingestion_run_id: str | None |
| 49 | + validation_report_uri: str | None |
| 50 | + refined_prefix: str |
| 51 | + dim_visitors_table_uri: str | None |
| 52 | + workflow_name: str | None |
| 53 | + workflow_run_id: str | None |
| 54 | + |
| 55 | + |
| 56 | +@dataclass(frozen=True) |
| 57 | +class RunInput: |
| 58 | + ingestion_run_id: str |
| 59 | + validation_report_uri: str |
| 60 | + |
| 61 | + |
| 62 | +def configure_logging() -> None: |
| 63 | + logging.basicConfig( |
| 64 | + level=logging.INFO, |
| 65 | + format="%(asctime)s %(levelname)s %(name)s %(message)s", |
| 66 | + force=True, |
| 67 | + ) |
| 68 | + |
| 69 | + |
| 70 | +def parse_optional_argument(name: str, default: str | None = None) -> str | None: |
| 71 | + flag = f"--{name}" |
| 72 | + if flag not in sys.argv: |
| 73 | + return default |
| 74 | + index = sys.argv.index(flag) |
| 75 | + if index + 1 >= len(sys.argv) or sys.argv[index + 1].startswith("--"): |
| 76 | + raise DimVisitorsError(f"{flag} requires a value.") |
| 77 | + return sys.argv[index + 1] |
| 78 | + |
| 79 | + |
| 80 | +def load_config() -> JobConfig: |
| 81 | + required = getResolvedOptions(sys.argv, ["JOB_NAME"]) |
| 82 | + refined_prefix = parse_optional_argument("REFINED_PREFIX", "refined/dim_visitors") |
| 83 | + assert refined_prefix is not None |
| 84 | + return JobConfig( |
| 85 | + job_name=required["JOB_NAME"], |
| 86 | + ingestion_run_id=parse_optional_argument("INGESTION_RUN_ID"), |
| 87 | + validation_report_uri=parse_optional_argument("VALIDATION_REPORT_URI"), |
| 88 | + refined_prefix=refined_prefix.strip("/"), |
| 89 | + dim_visitors_table_uri=parse_optional_argument("DIM_VISITORS_TABLE_URI"), |
| 90 | + workflow_name=parse_optional_argument("WORKFLOW_NAME"), |
| 91 | + workflow_run_id=parse_optional_argument("WORKFLOW_RUN_ID"), |
| 92 | + ) |
| 93 | + |
| 94 | + |
| 95 | +def workflow_context(config: JobConfig) -> tuple[str, str] | None: |
| 96 | + if not config.workflow_name and not config.workflow_run_id: |
| 97 | + return None |
| 98 | + if not config.workflow_name or not config.workflow_run_id: |
| 99 | + raise DimVisitorsError("WORKFLOW_NAME and WORKFLOW_RUN_ID must both be supplied.") |
| 100 | + return config.workflow_name, config.workflow_run_id |
| 101 | + |
| 102 | + |
| 103 | +def resolve_run_input(glue_client: Any, config: JobConfig) -> RunInput: |
| 104 | + if config.ingestion_run_id or config.validation_report_uri: |
| 105 | + if not config.ingestion_run_id or not config.validation_report_uri: |
| 106 | + raise DimVisitorsError( |
| 107 | + "INGESTION_RUN_ID and VALIDATION_REPORT_URI must both be supplied " |
| 108 | + "for a manual run." |
| 109 | + ) |
| 110 | + return RunInput( |
| 111 | + ingestion_run_id=config.ingestion_run_id, |
| 112 | + validation_report_uri=config.validation_report_uri, |
| 113 | + ) |
| 114 | + |
| 115 | + context = workflow_context(config) |
| 116 | + if context is None: |
| 117 | + raise DimVisitorsError( |
| 118 | + "No input was supplied. Provide INGESTION_RUN_ID and " |
| 119 | + "VALIDATION_REPORT_URI, or run the job in a Glue workflow." |
| 120 | + ) |
| 121 | + |
| 122 | + workflow_name, workflow_run_id = context |
| 123 | + try: |
| 124 | + properties = glue_client.get_workflow_run_properties( |
| 125 | + Name=workflow_name, |
| 126 | + RunId=workflow_run_id, |
| 127 | + ).get("RunProperties", {}) |
| 128 | + except (BotoCoreError, ClientError) as exc: |
| 129 | + raise DimVisitorsError( |
| 130 | + f"Unable to read properties for workflow {workflow_name!r}, " |
| 131 | + f"run {workflow_run_id!r}." |
| 132 | + ) from exc |
| 133 | + |
| 134 | + ingestion_run_id = properties.get("INGESTION_RUN_ID") |
| 135 | + validation_report_uri = properties.get("VALIDATION_REPORT_URI") |
| 136 | + if not ingestion_run_id or not validation_report_uri: |
| 137 | + raise DimVisitorsError( |
| 138 | + "The workflow run must contain INGESTION_RUN_ID and VALIDATION_REPORT_URI." |
| 139 | + ) |
| 140 | + return RunInput( |
| 141 | + ingestion_run_id=ingestion_run_id, |
| 142 | + validation_report_uri=validation_report_uri, |
| 143 | + ) |
| 144 | + |
| 145 | + |
| 146 | +def parse_s3_uri(uri: str) -> tuple[str, str]: |
| 147 | + parsed = urlparse(uri) |
| 148 | + if parsed.scheme != "s3" or not parsed.netloc or not parsed.path.lstrip("/"): |
| 149 | + raise DimVisitorsError(f"Invalid S3 URI: {uri!r}.") |
| 150 | + return parsed.netloc, parsed.path.lstrip("/") |
| 151 | + |
| 152 | + |
| 153 | +def read_validation_report(s3_client: Any, uri: str) -> dict[str, Any]: |
| 154 | + bucket, key = parse_s3_uri(uri) |
| 155 | + try: |
| 156 | + body = s3_client.get_object(Bucket=bucket, Key=key)["Body"].read() |
| 157 | + report = json.loads(body) |
| 158 | + except (BotoCoreError, ClientError, OSError, json.JSONDecodeError) as exc: |
| 159 | + raise DimVisitorsError(f"Unable to read validation report {uri}.") from exc |
| 160 | + if not isinstance(report, dict): |
| 161 | + raise DimVisitorsError(f"Expected a JSON object at {uri}.") |
| 162 | + return report |
| 163 | + |
| 164 | + |
| 165 | +def resolve_raw_input( |
| 166 | + report: dict[str, Any], |
| 167 | + expected_ingestion_run_id: str, |
| 168 | +) -> str: |
| 169 | + report_run_id = report.get("ingestion_run_id") |
| 170 | + if report_run_id != expected_ingestion_run_id: |
| 171 | + raise DimVisitorsError( |
| 172 | + f"Validation report run ID {report_run_id!r} does not match requested " |
| 173 | + f"run ID {expected_ingestion_run_id!r}." |
| 174 | + ) |
| 175 | + raw_uri = report.get("raw_s3_uri") |
| 176 | + if not isinstance(raw_uri, str): |
| 177 | + raise DimVisitorsError("Validation report is missing raw_s3_uri.") |
| 178 | + parse_s3_uri(raw_uri) |
| 179 | + return raw_uri |
| 180 | + |
| 181 | + |
| 182 | +def valid_record_count(report: dict[str, Any]) -> int: |
| 183 | + count = report.get("valid_record_count") |
| 184 | + if type(count) is not int or count < 0: |
| 185 | + raise DimVisitorsError("Validation report has an invalid valid_record_count.") |
| 186 | + return count |
| 187 | + |
| 188 | + |
| 189 | +def resolve_table_uri(raw_input_uri: str, config: JobConfig) -> str: |
| 190 | + if config.dim_visitors_table_uri: |
| 191 | + parse_s3_uri(config.dim_visitors_table_uri) |
| 192 | + return config.dim_visitors_table_uri.rstrip("/") |
| 193 | + bucket, _ = parse_s3_uri(raw_input_uri) |
| 194 | + return f"s3://{bucket}/{config.refined_prefix}" |
| 195 | + |
| 196 | + |
| 197 | +def build_dimension_dataframe(spark: Any, raw_input_uri: str) -> Any: |
| 198 | + from pyspark.sql import Window |
| 199 | + from pyspark.sql import functions as functions |
| 200 | + |
| 201 | + events = spark.read.json(raw_input_uri) |
| 202 | + required_columns = {"visitor_key", "ip", "country", "received_at"} |
| 203 | + missing_columns = sorted(required_columns.difference(events.columns)) |
| 204 | + if missing_columns: |
| 205 | + raise DimVisitorsError( |
| 206 | + f"Raw input is missing required columns: {', '.join(missing_columns)}." |
| 207 | + ) |
| 208 | + |
| 209 | + candidates = events.select( |
| 210 | + functions.col("visitor_key").alias("visitor_id"), |
| 211 | + functions.col("ip").alias("ip_address"), |
| 212 | + functions.col("country"), |
| 213 | + functions.to_timestamp("received_at").alias("_received_at"), |
| 214 | + ) |
| 215 | + latest_per_visitor = Window.partitionBy("visitor_id").orderBy( |
| 216 | + functions.col("_received_at").desc(), |
| 217 | + functions.col("ip_address").desc(), |
| 218 | + functions.col("country").desc(), |
| 219 | + ) |
| 220 | + return ( |
| 221 | + candidates.withColumn( |
| 222 | + "_row_number", |
| 223 | + functions.row_number().over(latest_per_visitor), |
| 224 | + ) |
| 225 | + .filter(functions.col("_row_number") == 1) |
| 226 | + .select("visitor_id", "ip_address", "country") |
| 227 | + ) |
| 228 | + |
| 229 | + |
| 230 | +def upsert_delta_table(spark: Any, dimension: Any, table_uri: str) -> int: |
| 231 | + from delta.tables import DeltaTable |
| 232 | + |
| 233 | + row_count = dimension.count() |
| 234 | + if row_count == 0: |
| 235 | + LOGGER.info("No visitor records were found; leaving the Delta table unchanged.") |
| 236 | + return 0 |
| 237 | + |
| 238 | + if DeltaTable.isDeltaTable(spark, table_uri): |
| 239 | + target = DeltaTable.forPath(spark, table_uri) |
| 240 | + ( |
| 241 | + target.alias("target") |
| 242 | + .merge( |
| 243 | + dimension.alias("source"), |
| 244 | + "target.visitor_id = source.visitor_id", |
| 245 | + ) |
| 246 | + .whenMatchedUpdateAll() |
| 247 | + .whenNotMatchedInsertAll() |
| 248 | + .execute() |
| 249 | + ) |
| 250 | + else: |
| 251 | + dimension.write.format("delta").mode("overwrite").save(table_uri) |
| 252 | + return row_count |
| 253 | + |
| 254 | + |
| 255 | +def publish_workflow_properties( |
| 256 | + glue_client: Any, |
| 257 | + config: JobConfig, |
| 258 | + run_input: RunInput, |
| 259 | + table_uri: str, |
| 260 | + row_count: int, |
| 261 | +) -> None: |
| 262 | + context = workflow_context(config) |
| 263 | + if context is None: |
| 264 | + LOGGER.info( |
| 265 | + "No Glue workflow context found; skipping dim_visitors property publication." |
| 266 | + ) |
| 267 | + return |
| 268 | + |
| 269 | + workflow_name, workflow_run_id = context |
| 270 | + try: |
| 271 | + glue_client.put_workflow_run_properties( |
| 272 | + Name=workflow_name, |
| 273 | + RunId=workflow_run_id, |
| 274 | + RunProperties={ |
| 275 | + "DIM_VISITORS_TABLE_URI": table_uri, |
| 276 | + "DIM_VISITORS_ROW_COUNT": str(row_count), |
| 277 | + "DIM_VISITORS_INGESTION_RUN_ID": run_input.ingestion_run_id, |
| 278 | + }, |
| 279 | + ) |
| 280 | + except (BotoCoreError, ClientError) as exc: |
| 281 | + raise DimVisitorsError( |
| 282 | + f"Unable to publish dim_visitors properties for workflow " |
| 283 | + f"{workflow_name!r}, run {workflow_run_id!r}." |
| 284 | + ) from exc |
| 285 | + |
| 286 | + |
| 287 | +def main() -> None: |
| 288 | + from awsglue.context import GlueContext |
| 289 | + from awsglue.job import Job |
| 290 | + from pyspark.context import SparkContext |
| 291 | + |
| 292 | + configure_logging() |
| 293 | + config = load_config() |
| 294 | + glue_client = boto3.client("glue") |
| 295 | + s3_client = boto3.client("s3") |
| 296 | + run_input = resolve_run_input(glue_client, config) |
| 297 | + report = read_validation_report(s3_client, run_input.validation_report_uri) |
| 298 | + raw_input_uri = resolve_raw_input(report, run_input.ingestion_run_id) |
| 299 | + input_record_count = valid_record_count(report) |
| 300 | + table_uri = resolve_table_uri(raw_input_uri, config) |
| 301 | + |
| 302 | + spark_context = SparkContext.getOrCreate() |
| 303 | + glue_context = GlueContext(spark_context) |
| 304 | + spark = glue_context.spark_session |
| 305 | + job = Job(glue_context) |
| 306 | + job.init(config.job_name, {}) |
| 307 | + |
| 308 | + LOGGER.info( |
| 309 | + "Building dim_visitors ingestion_run_id=%s raw_input_uri=%s table_uri=%s", |
| 310 | + run_input.ingestion_run_id, |
| 311 | + raw_input_uri, |
| 312 | + table_uri, |
| 313 | + ) |
| 314 | + if input_record_count == 0: |
| 315 | + LOGGER.info( |
| 316 | + "Validation report contains no valid records; leaving dim_visitors unchanged." |
| 317 | + ) |
| 318 | + row_count = 0 |
| 319 | + else: |
| 320 | + dimension = build_dimension_dataframe(spark, raw_input_uri) |
| 321 | + row_count = upsert_delta_table(spark, dimension, table_uri) |
| 322 | + publish_workflow_properties( |
| 323 | + glue_client=glue_client, |
| 324 | + config=config, |
| 325 | + run_input=run_input, |
| 326 | + table_uri=table_uri, |
| 327 | + row_count=row_count, |
| 328 | + ) |
| 329 | + LOGGER.info( |
| 330 | + "Completed dim_visitors upsert row_count=%s table_uri=%s ingestion_run_id=%s", |
| 331 | + row_count, |
| 332 | + table_uri, |
| 333 | + run_input.ingestion_run_id, |
| 334 | + ) |
| 335 | + job.commit() |
| 336 | + |
| 337 | + |
| 338 | +if __name__ == "__main__": |
| 339 | + try: |
| 340 | + main() |
| 341 | + except Exception: |
| 342 | + LOGGER.exception("dim_visitors build failed.") |
| 343 | + raise |
0 commit comments