99from functools import lru_cache
1010from textwrap import dedent
1111from threading import Lock
12- from typing import TYPE_CHECKING , Any , Dict , FrozenSet , Iterable , List , Optional , Set , Tuple , Type
12+ from typing import (
13+ TYPE_CHECKING ,
14+ Any ,
15+ Dict ,
16+ FrozenSet ,
17+ Generator ,
18+ Iterable ,
19+ List ,
20+ Optional ,
21+ Set ,
22+ Tuple ,
23+ Type ,
24+ Union ,
25+ )
1326from urllib .parse import urlparse
1427from uuid import uuid4
1528
5467from dbt .adapters .athena .s3 import S3DataNaming
5568from dbt .adapters .athena .utils import (
5669 AthenaCatalogType ,
70+ chunk_iterable ,
5771 clean_sql_comment ,
5872 ellipsis_comment ,
5973 get_catalog_id ,
@@ -134,6 +148,9 @@ class AthenaConfig(AdapterConfig):
134148class AthenaAdapter (SQLAdapter ):
135149 BATCH_CREATE_PARTITION_API_LIMIT = 100
136150 BATCH_DELETE_PARTITION_API_LIMIT = 25
151+ BATCH_DELETE_S3_OBJECTS_API_LIMIT = 1000
152+ PARTITION_PROCESSING_CHUNK_SIZE = 1000
153+ GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH = 2048
137154 INTEGER_MAX_VALUE_32_BIT_SIGNED = 0x7FFFFFFF
138155
139156 ConnectionManager = AthenaConnectionManager
@@ -403,7 +420,9 @@ def get_glue_table_location(self, relation: AthenaRelation) -> Optional[str]:
403420 return None
404421
405422 @available
406- def clean_up_partitions (self , relation : AthenaRelation , where_condition : str ) -> None :
423+ def clean_up_partitions (
424+ self , relation : AthenaRelation , where_condition : Union [str , List [str ]]
425+ ) -> None :
407426 conn = self .connections .get_thread_connection ()
408427 creds = conn .credentials
409428 client = conn .handle
@@ -417,24 +436,79 @@ def clean_up_partitions(self, relation: AthenaRelation, where_condition: str) ->
417436 region_name = client .region_name ,
418437 config = get_boto3_config (num_retries = creds .effective_num_retries ),
419438 )
420- paginator = glue_client .get_paginator ("get_partitions" )
421- partition_params = {
422- "CatalogId" : catalog_id ,
423- "DatabaseName" : relation .schema ,
424- "TableName" : relation .identifier ,
425- "Expression" : where_condition ,
426- "ExcludeColumnSchema" : True ,
427- }
428- partition_pg = paginator .paginate (** partition_params )
429- partitions = partition_pg .build_full_result ().get ("Partitions" )
430- for partition in partitions :
431- self .delete_from_s3 (partition ["StorageDescriptor" ]["Location" ])
432- glue_client .delete_partition (
433- CatalogId = catalog_id ,
434- DatabaseName = relation .schema ,
435- TableName = relation .identifier ,
436- PartitionValues = partition ["Values" ],
437- )
439+
440+ where_conditions = (
441+ [where_condition ] if isinstance (where_condition , str ) else where_condition
442+ )
443+
444+ def join_or_conditions (conditions : List [str ]) -> str :
445+ return " or " .join (conditions )
446+
447+ def get_partition_expressions () -> Generator [List [str ], None , None ]:
448+ current_chunk : List [str ] = []
449+ for condition in where_conditions :
450+ condition_with_brackets = f"({ condition } )"
451+ if (
452+ len (condition_with_brackets )
453+ > AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH
454+ ):
455+ raise DbtRuntimeError (
456+ f"Partition condition exceeds the Glue API expression limit of "
457+ f"{ AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH } characters: "
458+ f"'{ condition_with_brackets [:100 ]} ...'"
459+ )
460+ if current_chunk and (
461+ len (join_or_conditions (current_chunk + [condition_with_brackets ]))
462+ > AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH
463+ ):
464+ yield current_chunk
465+ current_chunk = []
466+ current_chunk .append (condition_with_brackets )
467+ if current_chunk :
468+ yield current_chunk
469+
470+ def iter_partitions () -> Generator [Dict [str , Any ], None , None ]:
471+ paginator = glue_client .get_paginator ("get_partitions" )
472+ for expression_chunk in get_partition_expressions ():
473+ expression = join_or_conditions (expression_chunk )
474+ partition_params = {
475+ "CatalogId" : catalog_id ,
476+ "DatabaseName" : relation .schema ,
477+ "TableName" : relation .identifier ,
478+ "Expression" : expression ,
479+ "ExcludeColumnSchema" : True ,
480+ }
481+ for page in paginator .paginate (** partition_params ):
482+ yield from page .get ("Partitions" , [])
483+
484+ def delete_partition_chunk (partitions ):
485+ self .bulk_delete_from_s3 ([p ["StorageDescriptor" ]["Location" ] for p in partitions ])
486+
487+ for glue_batch in get_chunks (
488+ partitions , AthenaAdapter .BATCH_DELETE_PARTITION_API_LIMIT
489+ ):
490+ response = glue_client .batch_delete_partition (
491+ CatalogId = catalog_id ,
492+ DatabaseName = relation .schema ,
493+ TableName = relation .identifier ,
494+ PartitionsToDelete = [{"Values" : p ["Values" ]} for p in glue_batch ],
495+ )
496+ if errors := response .get ("Errors" ):
497+ for err in errors :
498+ LOGGER .error (
499+ f"Failed to delete Glue partition: Values='{ err ['PartitionValues' ]} ', "
500+ f"Code='{ err ['ErrorDetail' ]['ErrorCode' ]} ', "
501+ f"Message='{ err ['ErrorDetail' ]['ErrorMessage' ]} '"
502+ )
503+ raise DbtRuntimeError (
504+ f"Failed to delete { len (errors )} partition(s) from Glue table "
505+ f"'{ relation .schema } .{ relation .identifier } '"
506+ )
507+
508+ for partition_params_chunk in chunk_iterable (
509+ iter_partitions (), AthenaAdapter .PARTITION_PROCESSING_CHUNK_SIZE
510+ ):
511+ delete_partition_chunk (partition_params_chunk )
438512
439513 @available
440514 def clean_up_table (self , relation : AthenaRelation ) -> None :
@@ -540,6 +614,68 @@ def delete_from_s3(self, s3_path: str) -> None:
540614 else :
541615 LOGGER .debug ("S3 path does not exist" )
542616
617+ def bulk_delete_from_s3 (self , s3_paths : List [str ]) -> None :
618+ if not s3_paths :
619+ LOGGER .debug ("No S3 paths provided for deletion" )
620+ return
621+
622+ conn = self .connections .get_thread_connection ()
623+ creds = conn .credentials
624+ client = conn .handle
625+ s3_resource = client .session .resource (
626+ "s3" ,
627+ region_name = client .region_name ,
628+ config = get_boto3_config (num_retries = creds .effective_num_retries ),
629+ )
630+
631+ # Group paths by bucket to support partitions spread across multiple buckets
632+ paths_by_bucket : Dict [str , List [str ]] = {}
633+ for s3_path in s3_paths :
634+ bucket , _ = self ._parse_s3_path (s3_path )
635+ paths_by_bucket .setdefault (bucket , []).append (s3_path )
636+
637+ def filter_objects_by_prefixes (
638+ s3_bucket : Any , bucket_paths : List [str ]
639+ ) -> Generator [Any , None , None ]:
640+ for s3_path in bucket_paths :
641+ LOGGER .debug (f"Listing files for deletion: { s3_path } " )
642+ _ , prefix = self ._parse_s3_path (s3_path )
643+ yield from s3_bucket .objects .filter (Prefix = prefix )
644+
645+ def chunk_object_keys (objects_iter ) -> Generator [List [Dict [str , str ]], None , None ]:
646+ chunk = []
647+ for obj in objects_iter :
648+ chunk .append ({"Key" : obj .key })
649+ if len (chunk ) >= AthenaAdapter .BATCH_DELETE_S3_OBJECTS_API_LIMIT :
650+ yield chunk
651+ chunk = []
652+ if chunk :
653+ yield chunk
654+
655+ for bucket_name , bucket_paths in paths_by_bucket .items ():
656+ s3_bucket = s3_resource .Bucket (bucket_name )
657+ for object_keys in chunk_object_keys (
658+ filter_objects_by_prefixes (s3_bucket , bucket_paths )
659+ ):
660+ if object_keys :
661+ LOGGER .debug (f"Calling delete_objects for { len (object_keys )} objects" )
662+ response = s3_bucket .delete_objects (Delete = {"Objects" : object_keys })
663+ deleted_count = len (response .get ("Deleted" , []))
664+ error_count = len (response .get ("Errors" , []))
665+ LOGGER .debug (
666+ f"delete_objects result: { deleted_count } deleted, { error_count } errors"
667+ )
668+ if errors := response .get ("Errors" ):
669+ for err in errors :
670+ LOGGER .error (
671+ f"Failed to delete S3 object: Key='{ err ['Key' ]} ', "
672+ f"Code='{ err ['Code' ]} ', Message='{ err ['Message' ]} ', "
673+ f"Bucket='{ bucket_name } '"
674+ )
675+ raise DbtRuntimeError (
676+ f"Failed to delete { len (errors )} object(s) from S3 bucket '{ bucket_name } '"
677+ )
678+
543679 @staticmethod
544680 def _parse_s3_path (s3_path : str ) -> Tuple [str , str ]:
545681 """
0 commit comments