Skip to content

Commit b4c6aba

Browse files
authored
feat: adds postgres writer (#18)
1 parent 0a2982c commit b4c6aba

4 files changed

Lines changed: 333 additions & 100 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ dependencies = [
2525
"jinja2>=3.1.0,<4.0",
2626
"requests>=2.32.0,<3.0",
2727
"tenacity>=9.1.0,<10.0",
28-
"pyspark>=3.5.0,<4.0",
28+
"pyspark>=4.1.0,<5.0",
2929
"aind-data-access-api[docdb]>=1.10.0,<2.0"
3030
]
3131

src/aind_vast_utils/compile_s3_metrics_job.py

Lines changed: 206 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,55 @@
77
import os
88
import re
99
import sys
10-
from typing import Dict, List, Tuple
10+
from typing import Any, Dict, List, Literal, Optional, Tuple
1111

1212
import boto3
1313
import pyspark.sql.functions as F
1414
from aind_data_access_api.document_db import MetadataDbClient
1515
from aind_settings_utils.aws import SecretsManagerBaseSettings
16-
from pydantic import Field
16+
from pydantic import BaseModel, Field, SecretStr, model_validator
1717
from pydantic_settings import SettingsConfigDict
1818
from pyspark import SparkConf
1919
from pyspark.sql import DataFrame, SparkSession
2020
from pyspark.sql.types import (
21-
BooleanType,
22-
IntegerType,
2321
StringType,
2422
StructField,
2523
StructType,
26-
TimestampType,
2724
)
2825

2926
logger = logging.getLogger(__name__)
3027
level = os.getenv("LOG_LEVEL", logging.INFO)
3128
logger.setLevel(level)
3229

33-
# Current schema of the csv files in the S3 Inventory report
34-
CSV_SCHEMA = StructType(
35-
[
36-
StructField("Bucket", StringType(), True),
37-
StructField("Key", StringType(), True),
38-
StructField("VersionId", StringType(), True),
39-
StructField("IsLatest", BooleanType(), True),
40-
StructField("IsDeleteMarker", BooleanType(), True),
41-
StructField("Size", IntegerType(), True),
42-
StructField("LastModifiedDate", TimestampType(), True),
43-
StructField("ETag", StringType(), True),
44-
StructField("StorageClass", StringType(), True),
45-
StructField("IntelligentTieringAccessTier", StringType(), True),
46-
]
47-
)
30+
31+
class OutputTarget(BaseModel):
32+
"""OutputTarget model."""
33+
34+
output_type: Literal["parquet", "postgres"] = Field("parquet")
35+
table_name: str = Field("weekly_report")
36+
output_location: Optional[str] = Field(None)
37+
db_username: Optional[str] = Field(None)
38+
db_password: Optional[SecretStr] = Field(None)
39+
db_url: Optional[str] = Field(None)
40+
db_save_mode: Literal["overwrite", "append", "ignore", "errorifexists"] = (
41+
Field(default="overwrite")
42+
)
43+
44+
@model_validator(mode="after")
45+
def check_output_type_requirements(self) -> "OutputTarget":
46+
"""Check fields are not None depending on format."""
47+
if self.output_type == "parquet" and self.output_location is None:
48+
raise ValueError(
49+
"output_location must be specified for parquet output_type!"
50+
)
51+
elif self.output_type == "postgres" and any(
52+
x is None
53+
for x in (self.db_username, self.db_password, self.db_url)
54+
):
55+
raise ValueError(
56+
"db settings must be specified for postgres output_type!"
57+
)
58+
return self
4859

4960

5061
class JobSettings(
@@ -56,6 +67,80 @@ class JobSettings(
5667

5768
# noinspection SpellCheckingInspection
5869
model_config = SettingsConfigDict(env_prefix="CompileS3MetricsJob_")
70+
inventory_format: Literal["csv", "parquet"] = Field(
71+
default="parquet",
72+
title="Inventory Format",
73+
description="File format the inventory is stored under",
74+
)
75+
inventory_schema: Dict[str, Any] = Field(
76+
default={
77+
"fields": [
78+
{
79+
"metadata": {},
80+
"name": "bucket",
81+
"nullable": True,
82+
"type": "string",
83+
},
84+
{
85+
"metadata": {},
86+
"name": "key",
87+
"nullable": True,
88+
"type": "string",
89+
},
90+
{
91+
"metadata": {},
92+
"name": "version_id",
93+
"nullable": True,
94+
"type": "string",
95+
},
96+
{
97+
"metadata": {},
98+
"name": "is_latest",
99+
"nullable": True,
100+
"type": "boolean",
101+
},
102+
{
103+
"metadata": {},
104+
"name": "is_delete_marker",
105+
"nullable": True,
106+
"type": "boolean",
107+
},
108+
{
109+
"metadata": {},
110+
"name": "size",
111+
"nullable": True,
112+
"type": "long",
113+
},
114+
{
115+
"metadata": {},
116+
"name": "last_modified_date",
117+
"nullable": True,
118+
"type": "timestamp",
119+
},
120+
{
121+
"metadata": {},
122+
"name": "e_tag",
123+
"nullable": True,
124+
"type": "string",
125+
},
126+
{
127+
"metadata": {},
128+
"name": "storage_class",
129+
"nullable": True,
130+
"type": "string",
131+
},
132+
{
133+
"metadata": {},
134+
"name": "intelligent_tiering_access_tier",
135+
"nullable": True,
136+
"type": "string",
137+
},
138+
],
139+
"type": "struct",
140+
},
141+
title="Inventory Schema",
142+
description="Schema of the inventory files",
143+
)
59144
s3_inventory_bucket: str = Field(
60145
...,
61146
title="S3 Inventory Bucket",
@@ -71,10 +156,10 @@ class JobSettings(
71156
title="S3 Bucket",
72157
description="The bucket that is being analyzed for it's metrics.",
73158
)
74-
output_location: str = Field(
75-
...,
76-
title="Output Location",
77-
description="Output location for writing dataframe",
159+
output_target: Optional[OutputTarget] = Field(
160+
None,
161+
title="Output Target",
162+
description="Output target for writing dataframe",
78163
)
79164
docdb_host: str = Field(
80165
...,
@@ -85,11 +170,11 @@ class JobSettings(
85170
{
86171
"spark.app.name": "S3InventoryMetrics",
87172
"spark.jars.packages": (
88-
"org.apache.hadoop:hadoop-aws:3.3.2,"
89-
"com.amazonaws:aws-java-sdk-bundle:1.11.1026"
173+
"org.apache.hadoop:hadoop-aws:3.4.1,"
174+
"com.amazonaws:aws-java-sdk-bundle:1.12.262"
90175
),
91176
"spark.hadoop.fs.s3a.aws.credentials.provider": (
92-
"com.amazonaws.auth.DefaultAWSCredentialsProviderChain"
177+
"com.amazonaws.auth.profile.ProfileCredentialsProvider"
93178
),
94179
}
95180
)
@@ -199,9 +284,36 @@ def _get_inventory_list(self, manifest_location: str) -> List[str]:
199284
s3_paths = [f"s3a://{bucket}/{obj_key}" for obj_key in object_keys]
200285
return s3_paths
201286

202-
def _get_inventory_df(
287+
def _get_inventory_df(self, s3_paths: List[str]) -> DataFrame:
288+
"""
289+
Get the inventory DataFrame from S3.
290+
291+
Parameters
292+
----------
293+
s3_paths : List[str]
294+
S3 paths of the files to be parsed.
295+
296+
Returns
297+
-------
298+
DataFrame
299+
300+
"""
301+
inventory_schema = StructType.fromJson(
302+
self.job_settings.inventory_schema
303+
)
304+
full_df = (
305+
self.spark.read.format(self.job_settings.inventory_format)
306+
.option("header", "false")
307+
.option("inferSchema", "false")
308+
.option("mode", "FAILFAST")
309+
.schema(inventory_schema)
310+
.load(s3_paths)
311+
)
312+
return full_df
313+
314+
def _transform_inventory_df(
203315
self,
204-
s3_paths: List[str],
316+
inventory_df: DataFrame,
205317
docdb_records: List[Tuple[str, str]],
206318
report_date: str,
207319
) -> DataFrame:
@@ -210,7 +322,7 @@ def _get_inventory_df(
210322
lazily evaluated.
211323
Parameters
212324
----------
213-
s3_paths : List[str]
325+
inventory_df : DataFrame
214326
docdb_records : List[Tuple[str, str]]
215327
report_date : str
216328
@@ -219,67 +331,57 @@ def _get_inventory_df(
219331
DataFrame
220332
Columns (
221333
bucket, prefix, subprefix, storage_class,
222-
intelligent_tiering_access_tier, size_in_bytes, project_name,
223-
report_date
334+
intelligent_tiering_access_tier, size_in_bytes, number_of_files,
335+
project_name, report_date
224336
)
225337
226338
"""
227-
full_df = (
228-
self.spark.read.format("csv")
229-
.option("header", "false")
230-
.schema(CSV_SCHEMA)
231-
.load(s3_paths)
232-
)
233339
# noinspection PyCallingNonCallable
234340
filtered_df = (
235-
full_df.withColumn(
236-
"Prefix", F.split(full_df["Key"], "/").getItem(0)
341+
inventory_df.withColumn(
342+
"prefix", F.split(inventory_df["key"], "/").getItem(0)
237343
)
238-
.withColumn("Subpath", F.split(full_df["Key"], "/").getItem(1))
239344
.withColumn(
240-
"Subprefix",
241-
F.concat_ws("/", F.col("Prefix"), F.col("Subpath")),
345+
"subpath", F.split(inventory_df["key"], "/").getItem(1)
346+
)
347+
.withColumn(
348+
"subprefix",
349+
F.concat_ws("/", F.col("prefix"), F.col("subpath")),
242350
)
243351
.where(
244-
(F.col("IsLatest") == True) # noqa: E712
245-
& (F.col("IsDeleteMarker") == False) # noqa: E712
352+
(F.col("is_latest") == True) # noqa: E712
353+
& (F.col("is_delete_marker") == False) # noqa: E712
246354
)
247355
.select(
248-
F.col("Bucket"),
249-
F.col("Prefix"),
250-
F.col("Subprefix"),
251-
F.col("Size"),
252-
F.col("StorageClass"),
253-
F.col("IntelligentTieringAccessTier"),
356+
F.col("bucket"),
357+
F.col("prefix"),
358+
F.col("subprefix"),
359+
F.col("size"),
360+
F.col("storage_class"),
361+
F.col("intelligent_tiering_access_tier"),
254362
)
255363
)
256364
docdb_df = self.spark.createDataFrame(
257-
docdb_records, ("Prefix", "ProjectName")
365+
data=docdb_records,
366+
schema=StructType(
367+
[
368+
StructField("prefix", StringType(), False),
369+
StructField("project_name", StringType(), True),
370+
]
371+
),
258372
)
259373
grouped_df = filtered_df.groupBy(
260-
"Bucket",
261-
"Prefix",
262-
"Subprefix",
263-
"StorageClass",
264-
"IntelligentTieringAccessTier",
265-
).sum("Size")
266-
joined_df = (
267-
grouped_df.join(docdb_df, "Prefix", "left").withColumn(
268-
"ReportDate", F.lit(report_date)
269-
)
270-
).withColumnsRenamed(
271-
{
272-
"Bucket": "bucket",
273-
"Prefix": "prefix",
274-
"Subprefix": "subprefix",
275-
"StorageClass": "storage_class",
276-
"IntelligentTieringAccessTier": (
277-
"intelligent_tiering_access_tier"
278-
),
279-
"sum(Size)": "size_in_bytes",
280-
"ProjectName": "project_name",
281-
"ReportDate": "report_date",
282-
}
374+
"bucket",
375+
"prefix",
376+
"subprefix",
377+
"storage_class",
378+
"intelligent_tiering_access_tier",
379+
).agg(
380+
F.sum("size").alias("size_in_bytes"),
381+
F.count("size").alias("number_of_files"),
382+
)
383+
joined_df = grouped_df.join(docdb_df, "prefix", "left").withColumn(
384+
"report_date", F.lit(report_date)
283385
)
284386
return joined_df
285387

@@ -291,8 +393,32 @@ def _write_df(self, df: DataFrame):
291393
df : DataFrame
292394
293395
"""
294-
output_location = self.job_settings.output_location
295-
df.write.parquet(output_location)
396+
output_target = self.job_settings.output_target
397+
if output_target is None:
398+
logger.info("No target set. Logging first few rows.")
399+
for row in df.limit(10).toLocalIterator():
400+
logger.info(f"{row}")
401+
elif output_target.output_type == "parquet":
402+
logger.info("Writing to local parquet files.")
403+
output_location = os.path.join(
404+
output_target.output_location, output_target.table_name
405+
)
406+
df.write.parquet(output_location)
407+
else:
408+
logger.info("Writing to postgres database.")
409+
properties = {
410+
"user": output_target.db_username,
411+
"password": output_target.db_password.get_secret_value(),
412+
"driver": "org.postgresql.Driver",
413+
"batchsize": "5000",
414+
"stringtype": "unspecified",
415+
}
416+
df.repartition(numPartitions=4).write.jdbc(
417+
url=output_target.db_url,
418+
table=output_target.table_name,
419+
mode=output_target.db_save_mode,
420+
properties=properties,
421+
)
296422

297423
def run_job(self):
298424
"""Compile the metrics and generate a report."""
@@ -309,8 +435,9 @@ def run_job(self):
309435
s3_paths = self._get_inventory_list(manifest_location=latest_manifest)
310436
logger.info(f"Inventory located across {len(s3_paths)} files.")
311437
logger.info("Defining DataFrame strategy. This may take a minute.")
312-
df = self._get_inventory_df(
313-
s3_paths=s3_paths,
438+
inventory_df = self._get_inventory_df(s3_paths=s3_paths)
439+
df = self._transform_inventory_df(
440+
inventory_df=inventory_df,
314441
docdb_records=docdb_records,
315442
report_date=report_date,
316443
)

0 commit comments

Comments
 (0)