Skip to content

Commit 48b7bc8

Browse files
Added build_dim_visitors.py glue job and associated tests.
1 parent da6499e commit 48b7bc8

3 files changed

Lines changed: 509 additions & 0 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,24 @@ The job does not register the Delta table in the Glue Data Catalog.
9191
The execution role needs `s3:GetObject` for the validation report and raw object,
9292
plus `s3:ListBucket` and `s3:PutObject` for the Delta table location. Include
9393
`s3:DeleteObject` if later maintenance or vacuum operations will remove Delta files.
94+
95+
## Refined dim_visitors
96+
97+
`build_dim_visitors.py` follows the same workflow/manual input contract and Delta
98+
configuration as `build_dim_media.py`. It upserts on `visitor_id` at:
99+
100+
```text
101+
s3://<data-lake-bucket>/refined/dim_visitors
102+
```
103+
104+
The table contains:
105+
106+
```text
107+
visitor_id <- visitor_key
108+
ip_address <- ip
109+
country
110+
```
111+
112+
When a visitor appears multiple times, the event with the latest `received_at`
113+
supplies the current IP address and country. Manual runs require
114+
`--INGESTION_RUN_ID` and `--VALIDATION_REPORT_URI`.

glue_jobs/build_dim_visitors.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)