Repository: duckdb/ducklake (2026-07-23T17:50:27Z)

Issue:
BUG: comment seems to left after column dropped
### What happens?

See repro SQL for details, should be straightforward.

### To Reproduce

```sql
# name: test/sql/comments/comment_drop_column.test
# description: dropping a column closes its active column tags
# group: [comments]

require ducklake

test-env DUCKLAKE_CONNECTION {TEST_DIR}/{UUID}.db

test-env DATA_PATH {TEST_DIR}

statement ok
ATTACH 'ducklake:{DUCKLAKE_CONNECTION}' AS ducklake (
    DATA_PATH '{DATA_PATH}/ducklake_comment_drop_column',
    METADATA_CATALOG 'metadata'
)

# A tag committed before the column is dropped must be closed with the column.
statement ok
CREATE TABLE ducklake.test(a INTEGER, b INTEGER)

statement ok
COMMENT ON COLUMN ducklake.test.b IS 'drop me'

statement ok
ALTER TABLE ducklake.test DROP COLUMN b

query I
SELECT count(*)
FROM metadata.ducklake_column_tag
WHERE end_snapshot IS NULL
----
0

# Adding, commenting on, and dropping a column in one transaction must not
# commit a tag for a column that never becomes visible.
statement ok
CREATE TABLE ducklake.same_transaction(a INTEGER)

statement ok
BEGIN

statement ok
ALTER TABLE ducklake.same_transaction ADD COLUMN b INTEGER

statement ok
COMMENT ON COLUMN ducklake.same_transaction.b IS 'never committed'

statement ok
ALTER TABLE ducklake.same_transaction DROP COLUMN b

statement ok
COMMIT

query I
SELECT count(*)
FROM metadata.ducklake_column_tag
WHERE end_snapshot IS NULL
----
0
```
gets error message
```sh
1. test/sql/comments/comment_drop_column.test:27
================================================================================
Wrong result in query! (test/sql/comments/comment_drop_column.test:27)!
================================================================================
SELECT count(*)
FROM metadata.ducklake_column_tag
WHERE end_snapshot IS NULL;
================================================================================
Mismatch on row 1, column count_star()(index 1)
1 <> 0
================================================================================
Expected result:
================================================================================
0

================================================================================
Actual result:
================================================================================
1
```

### OS:

macos

### DuckDB Version:

v1.5.4

### DuckLake Version:

34db89b27efe23a2037df83bf809720cad64d915

### DuckDB Client:

SQL

### Hardware:

_No response_

### Full Name:

dentiny

### Affiliation:

N/A

### What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.

I have tested with a source build

### Did you include all relevant data sets for reproducing the issue?

Yes

### Did you include all code required to reproduce the issue?

- [x] Yes, I have

### Did you include all relevant configuration (e.g., CPU architecture, Python version, Linux distribution) to reproduce the issue?

- [x] Yes, I have


Discussion:
Fixed in #1340.

Root cause: `WriteDroppedColumns` only expires `ducklake_column` rows; the drop path never touched `ducklake_column_tag`. Two subtleties handled in the fix:

1. RENAME/ALTER also route through `dropped_columns` (the field is re-added under the same field id), so tags are only expired for fields that are *not* re-added in the same commit — otherwise a rename would wrongly kill the column's comments.
2. `ADD COLUMN` + `COMMENT` + `DROP COLUMN` in one transaction emits no `dropped_columns` entry at all (`CancelOrDropField` just cancels the txn-local add), so tags queued for such fields are discarded in `HandleChangedFields` instead.

Your repro is included verbatim as the regression test, plus three boundary cases around renames (committed comment survives rename, comment+rename in one txn survives, comment+drop in one txn leaves nothing active).



Patch:
diff --git a/src/include/storage/ducklake_metadata_manager.hpp b/src/include/storage/ducklake_metadata_manager.hpp
index a5c7ba956d..270d61365f 100644
--- a/src/include/storage/ducklake_metadata_manager.hpp
+++ b/src/include/storage/ducklake_metadata_manager.hpp
@@ -276,6 +276,7 @@ class DuckLakeMetadataManager {
 	static string WriteNewSortKeys(const vector<DuckLakeSortInfo> &existing_sorts,
 	                               const vector<DuckLakeSortInfo> &new_sorts);
 	static string WriteDroppedColumns(const vector<DuckLakeDroppedColumn> &dropped_columns);
+	static string WriteExpiredColumnTags(const vector<DuckLakeDroppedColumn> &dropped_columns);
 	static string WriteNewColumns(const vector<DuckLakeNewColumn> &new_columns);
 	static string WriteNewTags(const vector<DuckLakeTagInfo> &new_tags);
 	static string WriteNewColumnTags(const vector<DuckLakeColumnTagInfo> &new_tags);
diff --git a/src/storage/ducklake_metadata_manager.cpp b/src/storage/ducklake_metadata_manager.cpp
index ca8e9bb713..1068bd176f 100644
--- a/src/storage/ducklake_metadata_manager.cpp
+++ b/src/storage/ducklake_metadata_manager.cpp
@@ -2867,6 +2867,30 @@ WHERE table_id=tid AND column_id=cid AND end_snapshot IS NULL
 	                          dropped_cols);
 }
 
+string DuckLakeMetadataManager::WriteExpiredColumnTags(const vector<DuckLakeDroppedColumn> &dropped_columns) {
+	if (dropped_columns.empty()) {
+		return {};
+	}
+	string dropped_cols;
+	for (auto &dropped_col : dropped_columns) {
+		if (!dropped_cols.empty()) {
+			dropped_cols += ", ";
+		}
+		dropped_cols += StringUtil::Format("(%d, %d)", dropped_col.table_id.index, dropped_col.field_id.index);
+	}
+	// expire any active tags of the dropped columns (GH #1310)
+	return StringUtil::Format(R"(
+WITH dropped_cols(tid, cid) AS (
+VALUES %s
+)
+UPDATE {METADATA_CATALOG}.ducklake_column_tag
+SET end_snapshot = {SNAPSHOT_ID}
+FROM dropped_cols
+WHERE table_id=tid AND column_id=cid AND end_snapshot IS NULL
+;)",
+	                          dropped_cols);
+}
+
 string DuckLakeMetadataManager::WriteNewColumns(const vector<DuckLakeNewColumn> &new_columns) {
 	if (new_columns.empty()) {
 		return {};
diff --git a/src/storage/ducklake_transaction_state.cpp b/src/storage/ducklake_transaction_state.cpp
index 45288f2205..bf8226afda 100644
--- a/src/storage/ducklake_transaction_state.cpp
+++ b/src/storage/ducklake_transaction_state.cpp
@@ -447,6 +447,28 @@ void HandleChangedFields(TableIndex table_id, const ColumnChangeInfo &change_inf
 		txn_added_fields[new_col_info.column_info.id.index] = result.new_columns.size();
 		result.new_columns.push_back(std::move(new_column));
 	}
+	// A field that is dropped without being re-added under the same field id in the same change
+	// (i.e. an actual DROP COLUMN, not a rename/alter type) ceases to exist - discard any column
+	// tags queued for it in this transaction; already-committed tags are expired when writing the
+	// dropped columns (GH #1310).
+	for (auto &dropped_field_id : change_info.dropped_fields) {
+		bool readded = false;
+		for (auto &new_col : change_info.new_fields) {
+			if (new_col.column_info.id.index == dropped_field_id.index) {
+				readded = true;
+				break;
+			}
+		}
+		if (readded) {
+			continue;
+		}
+		result.new_column_tags.erase(std::remove_if(result.new_column_tags.begin(), result.new_column_tags.end(),
+		                                            [&](const DuckLakeColumnTagInfo &tag) {
+			                                            return tag.table_id.index == table_id.index &&
+			                                                   tag.field_index.index == dropped_field_id.index;
+		                                            }),
+		                             result.new_column_tags.end());
+	}
 }
 
 void GetNewMacroInfo(DuckLakeCommitState &commit_state, reference<CatalogEntry> entry, NewMacroInfo &result) {
@@ -1625,6 +1647,23 @@ string DuckLakeTransactionState::CommitChanges(DuckLakeCommitState &commit_state
 			batch_queries += DuckLakeMetadataManager::WriteNewViewColumnTags(result.new_view_column_tags);
 		}
 		batch_queries += DuckLakeMetadataManager::WriteDroppedColumns(result.dropped_columns);
+		// Truly dropped columns - i.e. not re-added under the same field id in this commit, as
+		// happens for RENAME/ALTER - also expire their committed column tags (GH #1310).
+		vector<DuckLakeDroppedColumn> expired_column_tags;
+		for (auto &dropped_col : result.dropped_columns) {
+			bool readded = false;
+			for (auto &new_col : result.new_columns) {
+				if (new_col.table_id.index == dropped_col.table_id.index &&
+				    new_col.column_info.id.index == dropped_col.field_id.index) {
+					readded = true;
+					break;
+				}
+			}
+			if (!readded) {
+				expired_column_tags.push_back(dropped_col);
+			}
+		}
+		batch_queries += DuckLakeMetadataManager::WriteExpiredColumnTags(expired_column_tags);
 		batch_queries += DuckLakeMetadataManager::WriteNewColumns(result.new_columns);
 		batch_queries += context.write_inlined_tables(commit_snapshot, result.new_inlined_data_tables);
 		batch_queries += DuckLakeMetadataManager::WriteNewSortKeys(existing_catalog.sorts, result.new_sort_keys);


Test patch:
diff --git a/test/sql/comments/comment_drop_column.test b/test/sql/comments/comment_drop_column.test
new file mode 100644
index 0000000000..2c479c6833
--- /dev/null
+++ b/test/sql/comments/comment_drop_column.test
@@ -0,0 +1,124 @@
+# name: test/sql/comments/comment_drop_column.test
+# description: dropping a column closes its active column tags
+# group: [comments]
+
+require ducklake
+
+test-env DUCKLAKE_CONNECTION {TEST_DIR}/{UUID}.db
+
+test-env DATA_PATH {TEST_DIR}
+
+statement ok
+ATTACH 'ducklake:{DUCKLAKE_CONNECTION}' AS ducklake (
+    DATA_PATH '{DATA_PATH}/ducklake_comment_drop_column',
+    METADATA_CATALOG 'metadata'
+)
+
+# A tag committed before the column is dropped must be closed with the column.
+statement ok
+CREATE TABLE ducklake.test(a INTEGER, b INTEGER)
+
+statement ok
+COMMENT ON COLUMN ducklake.test.b IS 'drop me'
+
+statement ok
+ALTER TABLE ducklake.test DROP COLUMN b
+
+query I
+SELECT count(*)
+FROM metadata.ducklake_column_tag
+WHERE end_snapshot IS NULL
+----
+0
+
+# Adding, commenting on, and dropping a column in one transaction must not
+# commit a tag for a column that never becomes visible.
+statement ok
+CREATE TABLE ducklake.same_transaction(a INTEGER)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE ducklake.same_transaction ADD COLUMN b INTEGER
+
+statement ok
+COMMENT ON COLUMN ducklake.same_transaction.b IS 'never committed'
+
+statement ok
+ALTER TABLE ducklake.same_transaction DROP COLUMN b
+
+statement ok
+COMMIT
+
+query I
+SELECT count(*)
+FROM metadata.ducklake_column_tag
+WHERE end_snapshot IS NULL
+----
+0
+
+# Commenting on an existing column and dropping it in one transaction must not
+# leave an active tag behind either.
+statement ok
+CREATE TABLE ducklake.comment_then_drop(a INTEGER, b INTEGER)
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON COLUMN ducklake.comment_then_drop.b IS 'gone'
+
+statement ok
+ALTER TABLE ducklake.comment_then_drop DROP COLUMN b
+
+statement ok
+COMMIT
+
+query I
+SELECT count(*)
+FROM metadata.ducklake_column_tag
+WHERE end_snapshot IS NULL AND value = 'gone'
+----
+0
+
+# Renaming re-adds the field under the same field id, so a committed comment
+# must survive a rename.
+statement ok
+CREATE TABLE ducklake.renamed(a INTEGER, c INTEGER)
+
+statement ok
+COMMENT ON COLUMN ducklake.renamed.c IS 'keep me'
+
+statement ok
+ALTER TABLE ducklake.renamed RENAME COLUMN c TO d
+
+query I
+SELECT count(*)
+FROM metadata.ducklake_column_tag
+WHERE end_snapshot IS NULL AND value = 'keep me'
+----
+1
+
+# A comment set and renamed in one transaction must survive as well.
+statement ok
+CREATE TABLE ducklake.comment_then_rename(a INTEGER, c INTEGER)
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON COLUMN ducklake.comment_then_rename.c IS 'survives rename'
+
+statement ok
+ALTER TABLE ducklake.comment_then_rename RENAME COLUMN c TO d
+
+statement ok
+COMMIT
+
+query I
+SELECT count(*)
+FROM metadata.ducklake_column_tag
+WHERE end_snapshot IS NULL AND value = 'survives rename'
+----
+1