Skip to content

Commit afd64a3

Browse files
juhoautio-roviocolin-k-rogersCopilot
authored
Optimize Athena partition deletions for insert_overwrite strategy (#1558)
Co-authored-by: Colin Rogers <111200756+colin-rogers-dbt@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 5f0c504 commit afd64a3

6 files changed

Lines changed: 458 additions & 45 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Under the Hood
2+
body: Optimize Athena partition deletions for insert_overwrite strategy
3+
time: 2026-02-23T22:56:59.91236+02:00
4+
custom:
5+
Author: juhoautio-rovio
6+
Issue: "1125"

dbt-athena/src/dbt/adapters/athena/impl.py

Lines changed: 156 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,20 @@
99
from functools import lru_cache
1010
from textwrap import dedent
1111
from 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+
)
1326
from urllib.parse import urlparse
1427
from uuid import uuid4
1528

@@ -54,6 +67,7 @@
5467
from dbt.adapters.athena.s3 import S3DataNaming
5568
from 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):
132146
class 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
"""

dbt-athena/src/dbt/adapters/athena/utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import re
33
from enum import Enum
4-
from typing import Any, Generator, List, Optional, TypeVar
4+
from typing import Any, Generator, Iterable, List, Optional, TypeVar
55

66
from mypy_boto3_athena.type_defs import DataCatalogTypeDef
77

@@ -62,6 +62,18 @@ def get_chunks(lst: List[T], n: int) -> Generator[List[T], None, None]:
6262
yield lst[i : i + n]
6363

6464

65+
def chunk_iterable(iterable: Iterable[T], n: int) -> Generator[List[T], None, None]:
66+
"""Yield successive n-sized chunks from any iterable, including generators."""
67+
chunk = []
68+
for item in iterable:
69+
chunk.append(item)
70+
if len(chunk) >= n:
71+
yield chunk
72+
chunk = []
73+
if chunk:
74+
yield chunk
75+
76+
6577
def ellipsis_comment(s: str, max_len: int = 255) -> str:
6678
"""Ellipsis string if it exceeds max length"""
6779
return f"{s[:(max_len - 3)]}..." if len(s) > max_len else s

dbt-athena/src/dbt/include/athena/macros/materializations/models/incremental/helpers.sql

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,9 @@
111111
{%- do single_partition.append(partitioned_by[loop.index0] + '=' + value) -%}
112112
{%- endfor -%}
113113
{%- set single_partition_expression = single_partition | join(' and ') -%}
114-
{%- do partitions.append('(' + single_partition_expression + ')') -%}
115-
{%- endfor -%}
116-
{%- for i in range(partitions | length) %}
117-
{%- do adapter.clean_up_partitions(target_relation, partitions[i]) -%}
114+
{%- do partitions.append(single_partition_expression) -%}
118115
{%- endfor -%}
116+
{%- do adapter.clean_up_partitions(target_relation, partitions) -%}
119117
{%- endmacro %}
120118

121119
{% macro remove_partitions_from_columns(columns_with_partitions, partition_keys) %}

dbt-athena/tests/functional/adapter/test_unique_tmp_table_suffix.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
extract_running_create_statements,
77
)
88

9+
from dbt.adapters.athena.impl import AthenaAdapter
910
from dbt.contracts.results import RunStatus
1011
from dbt.tests.util import run_dbt
1112

@@ -19,7 +20,17 @@
1920
}}
2021
select
2122
random() as rnd,
22-
cast(from_iso8601_date('{{ var('logical_date') }}') as date) as date_column
23+
cast(date_column as date) as date_column
24+
from (
25+
values (
26+
sequence(
27+
from_iso8601_date('{{ var('start_date') }}'),
28+
from_iso8601_date('{{ var('end_date') }}'),
29+
interval '1' day
30+
)
31+
)
32+
) as t1(date_array)
33+
cross join unnest(date_array) as t2(date_column)
2334
"""
2435

2536

@@ -28,7 +39,7 @@ class TestUniqueTmpTableSuffix:
2839
def models(self):
2940
return {"unique_tmp_table_suffix.sql": models__unique_tmp_table_suffix_sql}
3041

31-
def test__unique_tmp_table_suffix(self, project, capsys):
42+
def test__unique_tmp_table_suffix(self, project, monkeypatch, capsys):
3243
relation_name = "unique_tmp_table_suffix"
3344
model_run_result_row_count_query = (
3445
f"select count(*) as records from {project.test_schema}.{relation_name}"
@@ -44,7 +55,7 @@ def test__unique_tmp_table_suffix(self, project, capsys):
4455
"--select",
4556
relation_name,
4657
"--vars",
47-
'{"logical_date": "2024-01-01"}',
58+
'{"start_date": "2024-01-01", "end_date": "2024-01-01"}',
4859
"--log-level",
4960
"debug",
5061
"--log-format",
@@ -70,19 +81,15 @@ def test__unique_tmp_table_suffix(self, project, capsys):
7081
re.search(expected_unique_table_name_re, first_model_run_result_table_name)
7182
)
7283

73-
records_count_first_run = project.run_sql(model_run_result_row_count_query, fetch="all")[
74-
0
75-
][0]
76-
77-
assert records_count_first_run == 1
84+
assert project.run_sql(model_run_result_row_count_query, fetch="all")[0][0] == 1
7885

7986
incremental_model_run = run_dbt(
8087
[
8188
"run",
8289
"--select",
8390
relation_name,
8491
"--vars",
85-
'{"logical_date": "2024-01-02"}',
92+
'{"start_date": "2024-01-02", "end_date": "2024-01-02"}',
8693
"--log-level",
8794
"debug",
8895
"--log-format",
@@ -110,13 +117,18 @@ def test__unique_tmp_table_suffix(self, project, capsys):
110117

111118
assert first_model_run_result_table_name != incremental_model_run_result_table_name
112119

120+
# Write 4 partitions with a monkeypatched expression limit to force chunking.
121+
# Each "(date_column='2024-01-0X')" is ~27 chars, so 2 fit per chunk (58 < 60),
122+
# requiring 2 Glue GetPartitions API calls for 4 partitions.
123+
monkeypatch.setattr(AthenaAdapter, "GET_PARTITIONS_API_EXPRESSION_MAX_LENGTH", 60)
124+
113125
incremental_model_run_2 = run_dbt(
114126
[
115127
"run",
116128
"--select",
117129
relation_name,
118130
"--vars",
119-
'{"logical_date": "2024-01-03"}',
131+
'{"start_date": "2024-01-01", "end_date": "2024-01-04"}',
120132
"--log-level",
121133
"debug",
122134
"--log-format",
@@ -136,5 +148,5 @@ def test__unique_tmp_table_suffix(self, project, capsys):
136148
)[0]
137149

138150
assert incremental_model_run_result_table_name != incremental_model_run_result_table_name_2
139-
140151
assert first_model_run_result_table_name != incremental_model_run_result_table_name_2
152+
assert project.run_sql(model_run_result_row_count_query, fetch="all")[0][0] == 4

0 commit comments

Comments
 (0)