Skip to content

Commit c8df9dc

Browse files
authored
Merge branch 'main' into data-contract-views
2 parents 4411bc9 + afd64a3 commit c8df9dc

53 files changed

Lines changed: 1821 additions & 97 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/_integration-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ jobs:
342342
AWS_REGION: ${{ vars.AWS_REGION }}
343343
REDSHIFT_TEST_DBNAME: ${{ vars.REDSHIFT_TEST_DBNAME }}
344344
REDSHIFT_TEST_CROSS_DBNAME: ${{ vars.REDSHIFT_TEST_CROSS_DBNAME }}
345+
REDSHIFT_TEST_DBNAME_W_HYPHEN: ${{ vars.REDSHIFT_TEST_DBNAME_W_HYPHEN }}
345346
REDSHIFT_TEST_PASS: ${{ secrets.REDSHIFT_TEST_PASS }}
346347
REDSHIFT_TEST_USER: ${{ vars.REDSHIFT_TEST_USER }}
347348
REDSHIFT_TEST_PORT: ${{ vars.REDSHIFT_TEST_PORT }}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Added --empty support for dbt seed
3+
time: 2026-04-15T13:48:09.244397+05:30
4+
custom:
5+
Author: sriramr98
6+
Issue: "1865"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixes
2+
body: Skip empty catalog tables before merge to prevent agate type conflicts during `dbt docs generate`
3+
time: 2026-04-13T12:00:00.000000+00:00
4+
custom:
5+
Author: tauhidanjum
6+
Issue: "1833"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2039,7 +2039,11 @@ def catch_as_completed(
20392039
# we want to re-raise on ctrl+c and BaseException
20402040
if exc is None:
20412041
catalog = future.result()
2042-
tables.append(catalog)
2042+
# Skip empty catalog results to avoid agate type conflicts.
2043+
# Empty results cause agate to infer text columns (e.g. column_name)
2044+
# as Number, which conflicts with Text when merged with non-empty tables.
2045+
if len(catalog) > 0:
2046+
tables.append(catalog)
20432047
elif isinstance(exc, KeyboardInterrupt) or not isinstance(exc, Exception):
20442048
raise exc
20452049
else:

dbt-adapters/src/dbt/include/global_project/macros/materializations/seeds/seed.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131

3232
{% set code = 'CREATE' if full_refresh_mode else 'INSERT' %}
3333
{% set rows_affected = (agate_table.rows | length) %}
34-
{% set sql = load_csv_rows(model, agate_table) %}
34+
{% set sql = "" %}
35+
{% if rows_affected > 0 %}
36+
{% set sql = load_csv_rows(model, agate_table) %}
37+
{% endif %}
3538

3639
{% call noop_statement('main', code ~ ' ' ~ rows_affected, code, rows_affected) %}
3740
{{ get_csv_sql(create_table_sql, sql) }};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from concurrent.futures import Future
2+
3+
import agate
4+
from dbt_common.clients.agate_helper import DEFAULT_TYPE_TESTER
5+
6+
from dbt.adapters.base.impl import catch_as_completed
7+
8+
9+
def _make_catalog_table(rows):
10+
"""Build an agate table mimicking catalog output."""
11+
return agate.Table.from_object(rows, column_types=DEFAULT_TYPE_TESTER)
12+
13+
14+
def _resolved_future(value):
15+
"""Create a Future that is already resolved with the given value."""
16+
f = Future()
17+
f.set_result(value)
18+
return f
19+
20+
21+
def _failed_future(exc):
22+
"""Create a Future that is already resolved with an exception."""
23+
f = Future()
24+
f.set_exception(exc)
25+
return f
26+
27+
28+
class TestCatchAsCompleted:
29+
def test_empty_table_excluded_from_merge(self):
30+
"""An empty catalog result should not cause a type conflict when merged
31+
with a non-empty result. This is the scenario from issue #1833: one schema
32+
returns rows (column_name inferred as Text) and another returns zero rows
33+
(column_name inferred as Number by agate default)."""
34+
non_empty = _make_catalog_table([{"column_name": "id", "column_type": "integer"}])
35+
empty = _make_catalog_table([])
36+
37+
futures = [_resolved_future(non_empty), _resolved_future(empty)]
38+
result, exceptions = catch_as_completed(futures)
39+
40+
assert len(exceptions) == 0
41+
assert len(result) == 1
42+
assert result[0]["column_name"] == "id"
43+
44+
def test_empty_table_with_conflicting_column_types_excluded(self):
45+
"""An empty table with explicit column schema whose types conflict with
46+
a non-empty table should be excluded. This covers the case where an adapter
47+
builds an empty table with column definitions (e.g. column_name as Number)
48+
that would conflict with Text in non-empty tables during merge."""
49+
non_empty = _make_catalog_table([{"column_name": "id", "column_type": "integer"}])
50+
# Build an empty table with explicit columns where column_name is Number —
51+
# this is the type conflict that causes the RuntimeError in agate.Table.merge()
52+
empty_with_schema = agate.Table(
53+
rows=[],
54+
column_names=["column_name", "column_type"],
55+
column_types=[agate.Number(), agate.Number()],
56+
)
57+
58+
futures = [_resolved_future(non_empty), _resolved_future(empty_with_schema)]
59+
result, exceptions = catch_as_completed(futures)
60+
61+
assert len(exceptions) == 0
62+
assert len(result) == 1
63+
assert result[0]["column_name"] == "id"
64+
65+
def test_all_empty_tables(self):
66+
"""When every schema returns empty results, merge should still succeed."""
67+
empty1 = _make_catalog_table([])
68+
empty2 = _make_catalog_table([])
69+
70+
futures = [_resolved_future(empty1), _resolved_future(empty2)]
71+
result, exceptions = catch_as_completed(futures)
72+
73+
assert len(exceptions) == 0
74+
assert len(result) == 0
75+
76+
def test_no_futures(self):
77+
"""No futures at all should return an empty table."""
78+
result, exceptions = catch_as_completed([])
79+
80+
assert len(exceptions) == 0
81+
assert len(result) == 0
82+
83+
def test_multiple_non_empty_tables_merged(self):
84+
"""Non-empty tables should still merge normally."""
85+
table1 = _make_catalog_table([{"column_name": "id", "column_type": "integer"}])
86+
table2 = _make_catalog_table([{"column_name": "name", "column_type": "text"}])
87+
88+
futures = [_resolved_future(table1), _resolved_future(table2)]
89+
result, exceptions = catch_as_completed(futures)
90+
91+
assert len(exceptions) == 0
92+
assert len(result) == 2
93+
94+
def test_exception_collected(self):
95+
"""Futures that raise exceptions should be collected, not crash."""
96+
non_empty = _make_catalog_table([{"column_name": "id", "column_type": "integer"}])
97+
futures = [
98+
_resolved_future(non_empty),
99+
_failed_future(RuntimeError("connection failed")),
100+
]
101+
result, exceptions = catch_as_completed(futures)
102+
103+
assert len(exceptions) == 1
104+
assert "connection failed" in str(exceptions[0])
105+
assert len(result) == 1
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Added support for --empty in dbt seed
3+
time: 2026-04-15T13:52:28.479796+05:30
4+
custom:
5+
Author: sriramr98
6+
Issue: "1865"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixes
2+
body: Fix get_partition_batches exceeding partition limit when combining non-bucket and bucket partitions
3+
time: 2026-03-20T01:56:50.322765+09:00
4+
custom:
5+
Author: dtaniwaki
6+
Issue: "1783"
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,
@@ -134,6 +148,9 @@ class AthenaConfig(AdapterConfig):
134148
class 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

Comments
 (0)