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 ,
@@ -132,6 +146,9 @@ class AthenaConfig(AdapterConfig):
132146class AthenaAdapter (SQLAdapter ):
133147 BATCH_CREATE_PARTITION_API_LIMIT = 100
134148 BATCH_DELETE_PARTITION_API_LIMIT = 25
149+ BATCH_DELETE_S3_OBJECTS_API_LIMIT = 1000
150+ PARTITION_PROCESSING_CHUNK_SIZE = 1000
151+ GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH = 2048
135152 INTEGER_MAX_VALUE_32_BIT_SIGNED = 0x7FFFFFFF
136153
137154 ConnectionManager = AthenaConnectionManager
@@ -401,7 +418,9 @@ def get_glue_table_location(self, relation: AthenaRelation) -> Optional[str]:
401418 return None
402419
403420 @available
404- def clean_up_partitions (self , relation : AthenaRelation , where_condition : str ) -> None :
421+ def clean_up_partitions (
422+ self , relation : AthenaRelation , where_condition : Union [str , List [str ]]
423+ ) -> None :
405424 conn = self .connections .get_thread_connection ()
406425 creds = conn .credentials
407426 client = conn .handle
@@ -415,24 +434,79 @@ def clean_up_partitions(self, relation: AthenaRelation, where_condition: str) ->
415434 region_name = client .region_name ,
416435 config = get_boto3_config (num_retries = creds .effective_num_retries ),
417436 )
418- paginator = glue_client .get_paginator ("get_partitions" )
419- partition_params = {
420- "CatalogId" : catalog_id ,
421- "DatabaseName" : relation .schema ,
422- "TableName" : relation .identifier ,
423- "Expression" : where_condition ,
424- "ExcludeColumnSchema" : True ,
425- }
426- partition_pg = paginator .paginate (** partition_params )
427- partitions = partition_pg .build_full_result ().get ("Partitions" )
428- for partition in partitions :
429- self .delete_from_s3 (partition ["StorageDescriptor" ]["Location" ])
430- glue_client .delete_partition (
431- CatalogId = catalog_id ,
432- DatabaseName = relation .schema ,
433- TableName = relation .identifier ,
434- PartitionValues = partition ["Values" ],
435- )
437+
438+ where_conditions = (
439+ [where_condition ] if isinstance (where_condition , str ) else where_condition
440+ )
441+
442+ def join_or_conditions (conditions : List [str ]) -> str :
443+ return " or " .join (conditions )
444+
445+ def get_partition_expressions () -> Generator [List [str ], None , None ]:
446+ current_chunk : List [str ] = []
447+ for condition in where_conditions :
448+ condition_with_brackets = f"({ condition } )"
449+ if (
450+ len (condition_with_brackets )
451+ > AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH
452+ ):
453+ raise DbtRuntimeError (
454+ f"Partition condition exceeds the Glue API expression limit of "
455+ f"{ AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH } characters: "
456+ f"'{ condition_with_brackets [:100 ]} ...'"
457+ )
458+ if current_chunk and (
459+ len (join_or_conditions (current_chunk + [condition_with_brackets ]))
460+ > AthenaAdapter .GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH
461+ ):
462+ yield current_chunk
463+ current_chunk = []
464+ current_chunk .append (condition_with_brackets )
465+ if current_chunk :
466+ yield current_chunk
467+
468+ def iter_partitions () -> Generator [Dict [str , Any ], None , None ]:
469+ paginator = glue_client .get_paginator ("get_partitions" )
470+ for expression_chunk in get_partition_expressions ():
471+ expression = join_or_conditions (expression_chunk )
472+ partition_params = {
473+ "CatalogId" : catalog_id ,
474+ "DatabaseName" : relation .schema ,
475+ "TableName" : relation .identifier ,
476+ "Expression" : expression ,
477+ "ExcludeColumnSchema" : True ,
478+ }
479+ for page in paginator .paginate (** partition_params ):
480+ yield from page .get ("Partitions" , [])
481+
482+ def delete_partition_chunk (partitions ):
483+ self .bulk_delete_from_s3 ([p ["StorageDescriptor" ]["Location" ] for p in partitions ])
484+
485+ for glue_batch in get_chunks (
486+ partitions , AthenaAdapter .BATCH_DELETE_PARTITION_API_LIMIT
487+ ):
488+ response = glue_client .batch_delete_partition (
489+ CatalogId = catalog_id ,
490+ DatabaseName = relation .schema ,
491+ TableName = relation .identifier ,
492+ PartitionsToDelete = [{"Values" : p ["Values" ]} for p in glue_batch ],
493+ )
494+ if errors := response .get ("Errors" ):
495+ for err in errors :
496+ LOGGER .error (
497+ f"Failed to delete Glue partition: Values='{ err ['PartitionValues' ]} ', "
498+ f"Code='{ err ['ErrorDetail' ]['ErrorCode' ]} ', "
499+ f"Message='{ err ['ErrorDetail' ]['ErrorMessage' ]} '"
500+ )
501+ raise DbtRuntimeError (
502+ f"Failed to delete { len (errors )} partition(s) from Glue table "
503+ f"'{ relation .schema } .{ relation .identifier } '"
504+ )
505+
506+ for partition_params_chunk in chunk_iterable (
507+ iter_partitions (), AthenaAdapter .PARTITION_PROCESSING_CHUNK_SIZE
508+ ):
509+ delete_partition_chunk (partition_params_chunk )
436510
437511 @available
438512 def clean_up_table (self , relation : AthenaRelation ) -> None :
@@ -538,6 +612,68 @@ def delete_from_s3(self, s3_path: str) -> None:
538612 else :
539613 LOGGER .debug ("S3 path does not exist" )
540614
615+ def bulk_delete_from_s3 (self , s3_paths : List [str ]) -> None :
616+ if not s3_paths :
617+ LOGGER .debug ("No S3 paths provided for deletion" )
618+ return
619+
620+ conn = self .connections .get_thread_connection ()
621+ creds = conn .credentials
622+ client = conn .handle
623+ s3_resource = client .session .resource (
624+ "s3" ,
625+ region_name = client .region_name ,
626+ config = get_boto3_config (num_retries = creds .effective_num_retries ),
627+ )
628+
629+ # Group paths by bucket to support partitions spread across multiple buckets
630+ paths_by_bucket : Dict [str , List [str ]] = {}
631+ for s3_path in s3_paths :
632+ bucket , _ = self ._parse_s3_path (s3_path )
633+ paths_by_bucket .setdefault (bucket , []).append (s3_path )
634+
635+ def filter_objects_by_prefixes (
636+ s3_bucket : Any , bucket_paths : List [str ]
637+ ) -> Generator [Any , None , None ]:
638+ for s3_path in bucket_paths :
639+ LOGGER .debug (f"Listing files for deletion: { s3_path } " )
640+ _ , prefix = self ._parse_s3_path (s3_path )
641+ yield from s3_bucket .objects .filter (Prefix = prefix )
642+
643+ def chunk_object_keys (objects_iter ) -> Generator [List [Dict [str , str ]], None , None ]:
644+ chunk = []
645+ for obj in objects_iter :
646+ chunk .append ({"Key" : obj .key })
647+ if len (chunk ) >= AthenaAdapter .BATCH_DELETE_S3_OBJECTS_API_LIMIT :
648+ yield chunk
649+ chunk = []
650+ if chunk :
651+ yield chunk
652+
653+ for bucket_name , bucket_paths in paths_by_bucket .items ():
654+ s3_bucket = s3_resource .Bucket (bucket_name )
655+ for object_keys in chunk_object_keys (
656+ filter_objects_by_prefixes (s3_bucket , bucket_paths )
657+ ):
658+ if object_keys :
659+ LOGGER .debug (f"Calling delete_objects for { len (object_keys )} objects" )
660+ response = s3_bucket .delete_objects (Delete = {"Objects" : object_keys })
661+ deleted_count = len (response .get ("Deleted" , []))
662+ error_count = len (response .get ("Errors" , []))
663+ LOGGER .debug (
664+ f"delete_objects result: { deleted_count } deleted, { error_count } errors"
665+ )
666+ if errors := response .get ("Errors" ):
667+ for err in errors :
668+ LOGGER .error (
669+ f"Failed to delete S3 object: Key='{ err ['Key' ]} ', "
670+ f"Code='{ err ['Code' ]} ', Message='{ err ['Message' ]} ', "
671+ f"Bucket='{ bucket_name } '"
672+ )
673+ raise DbtRuntimeError (
674+ f"Failed to delete { len (errors )} object(s) from S3 bucket '{ bucket_name } '"
675+ )
676+
541677 @staticmethod
542678 def _parse_s3_path (s3_path : str ) -> Tuple [str , str ]:
543679 """
0 commit comments