77import os
88import re
99import sys
10- from typing import Dict , List , Tuple
10+ from typing import Any , Dict , List , Literal , Optional , Tuple
1111
1212import boto3
1313import pyspark .sql .functions as F
1414from aind_data_access_api .document_db import MetadataDbClient
1515from aind_settings_utils .aws import SecretsManagerBaseSettings
16- from pydantic import Field
16+ from pydantic import BaseModel , Field , SecretStr , model_validator
1717from pydantic_settings import SettingsConfigDict
1818from pyspark import SparkConf
1919from pyspark .sql import DataFrame , SparkSession
2020from pyspark .sql .types import (
21- BooleanType ,
22- IntegerType ,
2321 StringType ,
2422 StructField ,
2523 StructType ,
26- TimestampType ,
2724)
2825
2926logger = logging .getLogger (__name__ )
3027level = os .getenv ("LOG_LEVEL" , logging .INFO )
3128logger .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
5061class 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