Skip to content

Commit cfbc07c

Browse files
authored
Merge pull request #1655 from Altinity/feature/iceberg-truncate-26.3
feature (iceberg): Implement TRUNCATE TABLE for Iceberg Engine (REST …
2 parents 59bfcc0 + cc204b8 commit cfbc07c

9 files changed

Lines changed: 276 additions & 10 deletions

File tree

‎src/Storages/ObjectStorage/DataLakes/IDataLakeMetadata.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ class IDataLakeMetadata : boost::noncopyable
269269
throwNotImplemented(fmt::format("EXECUTE {}", command_name));
270270
}
271271

272+
virtual bool supportsTruncate() const { return false; }
273+
virtual void truncate(ContextPtr /*context*/, std::shared_ptr<DataLake::ICatalog> /*catalog*/, const StorageID & /*storage_id*/)
274+
{
275+
throwNotImplemented("truncate");
276+
}
277+
272278
virtual void drop(ContextPtr) { }
273279

274280
virtual std::optional<String> partitionKey(ContextPtr) const { return {}; }

‎src/Storages/ObjectStorage/DataLakes/Iceberg/Constant.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ DEFINE_ICEBERG_FIELD_ALIAS(partition_spec, partition-spec);
113113
DEFINE_ICEBERG_FIELD_ALIAS(partition_specs, partition-specs);
114114
DEFINE_ICEBERG_FIELD_ALIAS(spec_id, spec-id);
115115
DEFINE_ICEBERG_FIELD_ALIAS(added_records, added-records);
116+
DEFINE_ICEBERG_FIELD_ALIAS(deleted_records, deleted-records);
116117
DEFINE_ICEBERG_FIELD_ALIAS(added_data_files, added-data-files);
118+
DEFINE_ICEBERG_FIELD_ALIAS(deleted_data_files, deleted-data-files);
117119
DEFINE_ICEBERG_FIELD_ALIAS(added_delete_files, added-delete-files);
118120
DEFINE_ICEBERG_FIELD_ALIAS(added_position_delete_files, added-position-delete-files);
119121
DEFINE_ICEBERG_FIELD_ALIAS(added_position_deletes, added-position-deletes);

‎src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
#include <Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.h>
7070
#include <Storages/ObjectStorage/DataLakes/Iceberg/IcebergTableStateSnapshot.h>
7171
#include <Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.h>
72+
#include <Storages/ObjectStorage/DataLakes/Iceberg/MetadataGenerator.h>
7273
#include <Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.h>
7374
#include <Storages/ObjectStorage/DataLakes/Iceberg/ManifestFilesPruning.h>
7475
#include <Storages/ObjectStorage/DataLakes/Iceberg/Mutations.h>
@@ -110,6 +111,7 @@ extern const int ICEBERG_SPECIFICATION_VIOLATION;
110111
extern const int S3_ERROR;
111112
extern const int TABLE_ALREADY_EXISTS;
112113
extern const int SUPPORT_IS_DISABLED;
114+
extern const int INCORRECT_DATA;
113115
}
114116

115117
namespace Setting
@@ -311,15 +313,17 @@ void IcebergMetadata::backgroundMetadataPrefetcherThread()
311313
/// first, we fetch the latest metadata version and cache it;
312314
/// as a part of the same method, we download metadata.json of the latest metadata version
313315
/// and after parsing it, we fetch manifest lists, parse and cache them
314-
auto ctx = Context::getGlobalContextInstance()->getBackgroundContext();
316+
auto ctx = Context::createCopy(Context::getGlobalContextInstance());
315317
auto [actual_data_snapshot, actual_table_state_snapshot] = getRelevantState(ctx, true);
316318
if (actual_data_snapshot)
317319
{
318320
for (const auto & entry : actual_data_snapshot->manifest_list_entries)
319321
{
320322
/// second, we fetch, parse and cache each manifest file
321-
auto manifest_file_ptr = getManifestFileEntriesHandle(
322-
object_storage, persistent_components, ctx, log, entry, actual_table_state_snapshot.schema_id);
323+
auto manifest_file_ptr = Iceberg::getManifestFile(
324+
object_storage, persistent_components, ctx, log,
325+
entry.manifest_file_path,
326+
entry.manifest_file_byte_size);
323327
}
324328
}
325329

@@ -658,6 +662,100 @@ void IcebergMetadata::mutate(
658662
);
659663
}
660664

665+
void IcebergMetadata::truncate(ContextPtr context, std::shared_ptr<DataLake::ICatalog> catalog, const StorageID & storage_id)
666+
{
667+
if (!context->getSettingsRef()[Setting::allow_insert_into_iceberg].value)
668+
throw Exception(
669+
ErrorCodes::SUPPORT_IS_DISABLED,
670+
"Iceberg truncate requires the allow_insert_into_iceberg setting to be enabled.");
671+
672+
auto [actual_data_snapshot, actual_table_state_snapshot] = getRelevantState(context);
673+
auto metadata_object = getMetadataJSONObject(
674+
actual_table_state_snapshot.metadata_file_path,
675+
object_storage,
676+
persistent_components.metadata_cache,
677+
context,
678+
log,
679+
persistent_components.metadata_compression_method,
680+
persistent_components.table_uuid);
681+
682+
// Use -1 as the Iceberg spec sentinel for "no parent snapshot"
683+
// (distinct from snapshot ID 0 which is a valid snapshot).
684+
Int64 parent_snapshot_id = actual_table_state_snapshot.snapshot_id.value_or(-1);
685+
686+
auto config_path = persistent_components.table_path;
687+
if (!config_path.starts_with('/')) config_path = '/' + config_path;
688+
if (!config_path.ends_with('/')) config_path += "/";
689+
690+
bool is_transactional = (catalog != nullptr && catalog->isTransactional());
691+
692+
// Transactional catalogs (e.g. REST) require a fully-qualified blob URI
693+
// (scheme://bucket/path) so the catalog can resolve the metadata location
694+
// independently of any local path configuration. Non-transactional catalogs
695+
// use bare paths relative to the object storage root.
696+
FileNamesGenerator filename_generator;
697+
if (is_transactional || context->getSettingsRef()[Setting::write_full_path_in_iceberg_metadata])
698+
{
699+
String location = metadata_object->getValue<String>(Iceberg::f_location);
700+
if (!location.ends_with("/")) location += "/";
701+
filename_generator = FileNamesGenerator(
702+
location, config_path, is_transactional,
703+
persistent_components.metadata_compression_method, write_format);
704+
}
705+
else
706+
{
707+
filename_generator = FileNamesGenerator(
708+
config_path, config_path, false,
709+
persistent_components.metadata_compression_method, write_format);
710+
}
711+
712+
Int32 new_metadata_version = actual_table_state_snapshot.metadata_version + 1;
713+
filename_generator.setVersion(new_metadata_version);
714+
715+
auto [metadata_name, storage_metadata_name] = filename_generator.generateMetadataName();
716+
717+
auto [new_snapshot, manifest_list_name, storage_manifest_list_name] = MetadataGenerator(metadata_object).generateNextMetadata(
718+
filename_generator, metadata_name, parent_snapshot_id,
719+
/* added_files */ 0, /* added_records */ 0, /* added_files_size */ 0,
720+
/* num_partitions */ 0, /* added_delete_files */ 0, /* num_deleted_rows */ 0,
721+
std::nullopt, std::nullopt, /*is_truncate=*/true);
722+
723+
auto write_settings = context->getWriteSettings();
724+
auto buf = object_storage->writeObject(
725+
StoredObject(storage_manifest_list_name),
726+
WriteMode::Rewrite, std::nullopt,
727+
DBMS_DEFAULT_BUFFER_SIZE, write_settings);
728+
729+
generateManifestList(filename_generator, metadata_object, object_storage,
730+
context, {}, new_snapshot, 0, *buf, Iceberg::FileContentType::DATA, /*use_previous_snapshots=*/false);
731+
buf->finalize();
732+
733+
String metadata_content = dumpMetadataObjectToString(metadata_object);
734+
writeMessageToFile(metadata_content, storage_metadata_name, object_storage,
735+
context, "*", "", persistent_components.metadata_compression_method);
736+
737+
if (catalog)
738+
{
739+
// Transactional catalogs require a fully-qualified blob URI so the catalog
740+
// can resolve the metadata location independently of local path configuration.
741+
String catalog_filename = metadata_name;
742+
if (is_transactional)
743+
{
744+
// Build full URI from the table's location field (e.g. "s3://bucket/namespace.table")
745+
// combined with the relative metadata name.
746+
String location = metadata_object->getValue<String>(Iceberg::f_location);
747+
if (!location.ends_with("/")) location += "/";
748+
catalog_filename = location + metadata_name;
749+
}
750+
751+
const auto & [namespace_name, table_name] = DataLake::parseTableName(storage_id.getTableName());
752+
if (!catalog->updateMetadata(namespace_name, table_name, catalog_filename, new_snapshot))
753+
throw Exception(ErrorCodes::INCORRECT_DATA,
754+
"Failed to commit Iceberg truncate update to catalog.");
755+
}
756+
}
757+
758+
661759
void IcebergMetadata::checkMutationIsPossible(const MutationCommands & commands)
662760
{
663761
for (const auto & command : commands)

‎src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ class IcebergMetadata : public IDataLakeMetadata
8989
bool supportsUpdate() const override { return true; }
9090
bool supportsWrites() const override { return true; }
9191
bool supportsParallelInsert() const override { return true; }
92+
bool supportsTruncate() const override { return true; }
93+
void truncate(ContextPtr context, std::shared_ptr<DataLake::ICatalog> catalog, const StorageID & storage_id) override;
9294

9395
IcebergHistory getHistory(ContextPtr local_context) const;
9496

‎src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,31 @@ void generateManifestFile(
647647
writer.close();
648648
}
649649

650+
// Avro uses zigzag encoding for integers to efficiently represent small negative
651+
// numbers. Positive n maps to 2n, negative n maps to 2(-n)-1, keeping small
652+
// magnitudes compact regardless of sign. The value is then serialized as a
653+
// variable-length base-128 integer (little-endian), where the high bit of each
654+
// byte signals whether more bytes follow.
655+
// See: https://avro.apache.org/docs/1.11.1/specification/#binary-encoding
656+
static void writeAvroLong(WriteBuffer & out, int64_t val)
657+
{
658+
uint64_t n = (static_cast<uint64_t>(val) << 1) ^ static_cast<uint64_t>(val >> 63);
659+
while (n & ~0x7fULL)
660+
{
661+
char c = static_cast<char>((n & 0x7f) | 0x80);
662+
out.write(&c, 1);
663+
n >>= 7;
664+
}
665+
char c = static_cast<char>(n);
666+
out.write(&c, 1);
667+
}
668+
669+
static void writeAvroBytes(WriteBuffer & out, const String & s)
670+
{
671+
writeAvroLong(out, static_cast<int64_t>(s.size()));
672+
out.write(s.data(), s.size());
673+
}
674+
650675
void generateManifestList(
651676
const FileNamesGenerator & filename_generator,
652677
Poco::JSON::Object::Ptr metadata,
@@ -668,6 +693,38 @@ void generateManifestList(
668693
else
669694
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Unknown iceberg version {}", version);
670695

696+
// For empty manifest list (e.g. TRUNCATE), write a valid Avro container
697+
// file manually so we can embed the full schema JSON with field-ids intact,
698+
// without triggering the DataFileWriter constructor's eager writeHeader()
699+
// which commits encoder state before we can override avro.schema.
700+
if (manifest_entry_names.empty() && !use_previous_snapshots)
701+
{
702+
// For an empty manifest list (e.g. after TRUNCATE), we write a minimal valid
703+
// Avro Object Container File manually rather than using avro::DataFileWriter.
704+
// The reason: DataFileWriter calls writeHeader() eagerly in its constructor,
705+
// committing the binary encoder state. Post-construction setMetadata() calls
706+
// corrupt StreamWriter::next_ causing a NULL dereference on close(). Writing
707+
// the OCF header directly ensures the full schema JSON (with Iceberg field-ids)
708+
// is embedded intact — the Avro C++ library strips unknown field properties
709+
// like field-id during schema node serialization.
710+
// Avro OCF format: [magic(4)] [metadata_map] [sync_marker(16)] [no data blocks]
711+
buf.write("Obj\x01", 4);
712+
713+
writeAvroLong(buf, 2); // 2 metadata entries
714+
writeAvroBytes(buf, "avro.codec");
715+
writeAvroBytes(buf, "null");
716+
writeAvroBytes(buf, "avro.schema");
717+
writeAvroBytes(buf, schema_representation); // full JSON with field-ids intact
718+
719+
writeAvroLong(buf, 0); // end of metadata map
720+
721+
static const char sync_marker[16] = {};
722+
buf.write(sync_marker, 16);
723+
724+
buf.finalize();
725+
return;
726+
}
727+
671728
auto schema = avro::compileJsonSchemaFromString(schema_representation); // NOLINT
672729

673730
auto adapter = std::make_unique<OutputStreamWriteBufferAdapter>(buf);

‎src/Storages/ObjectStorage/DataLakes/Iceberg/MetadataGenerator.cpp‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ MetadataGenerator::NextMetadataResult MetadataGenerator::generateNextMetadata(
114114
Int64 added_delete_files,
115115
Int64 num_deleted_rows,
116116
std::optional<Int64> user_defined_snapshot_id,
117-
std::optional<Int64> user_defined_timestamp)
117+
std::optional<Int64> user_defined_timestamp,
118+
bool is_truncate)
118119
{
119120
int format_version = metadata_object->getValue<Int32>(Iceberg::f_format_version);
120121
Poco::JSON::Object::Ptr new_snapshot = new Poco::JSON::Object;
@@ -138,7 +139,16 @@ MetadataGenerator::NextMetadataResult MetadataGenerator::generateNextMetadata(
138139

139140
auto parent_snapshot = getParentSnapshot(parent_snapshot_id);
140141
Poco::JSON::Object::Ptr summary = new Poco::JSON::Object;
141-
if (num_deleted_rows == 0)
142+
if (is_truncate)
143+
{
144+
summary->set(Iceberg::f_operation, Iceberg::f_overwrite);
145+
Int32 prev_total_records = parent_snapshot && parent_snapshot->has(Iceberg::f_summary) && parent_snapshot->getObject(Iceberg::f_summary)->has(Iceberg::f_total_records) ? std::stoi(parent_snapshot->getObject(Iceberg::f_summary)->getValue<String>(Iceberg::f_total_records)) : 0;
146+
Int32 prev_total_data_files = parent_snapshot && parent_snapshot->has(Iceberg::f_summary) && parent_snapshot->getObject(Iceberg::f_summary)->has(Iceberg::f_total_data_files) ? std::stoi(parent_snapshot->getObject(Iceberg::f_summary)->getValue<String>(Iceberg::f_total_data_files)) : 0;
147+
148+
summary->set(Iceberg::f_deleted_records, std::to_string(prev_total_records));
149+
summary->set(Iceberg::f_deleted_data_files, std::to_string(prev_total_data_files));
150+
}
151+
else if (num_deleted_rows == 0)
142152
{
143153
summary->set(Iceberg::f_operation, Iceberg::f_append);
144154
summary->set(Iceberg::f_added_data_files, std::to_string(added_files));
@@ -158,7 +168,12 @@ MetadataGenerator::NextMetadataResult MetadataGenerator::generateNextMetadata(
158168

159169
auto sum_with_parent_snapshot = [&](const char * field_name, Int64 snapshot_value)
160170
{
161-
Int64 prev_value = parent_snapshot ? parse<Int64>(parent_snapshot->getObject(Iceberg::f_summary)->getValue<String>(field_name)) : 0;
171+
if (is_truncate)
172+
{
173+
summary->set(field_name, std::to_string(0));
174+
return;
175+
}
176+
Int64 prev_value = parent_snapshot && parent_snapshot->has(Iceberg::f_summary) && parent_snapshot->getObject(Iceberg::f_summary)->has(field_name) ? parse<Int64>(parent_snapshot->getObject(Iceberg::f_summary)->getValue<String>(field_name)) : 0;
162177
summary->set(field_name, std::to_string(prev_value + snapshot_value));
163178
};
164179

‎src/Storages/ObjectStorage/DataLakes/Iceberg/MetadataGenerator.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class MetadataGenerator
3030
Int64 added_delete_files,
3131
Int64 num_deleted_rows,
3232
std::optional<Int64> user_defined_snapshot_id = std::nullopt,
33-
std::optional<Int64> user_defined_timestamp = std::nullopt);
33+
std::optional<Int64> user_defined_timestamp = std::nullopt,
34+
bool is_truncate = false);
3435

3536
void generateAddColumnMetadata(const String & column_name, DataTypePtr type);
3637
void generateDropColumnMetadata(const String & column_name);

‎src/Storages/ObjectStorage/StorageObjectStorage.cpp‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -723,7 +723,7 @@ void StorageObjectStorage::commitExportPartitionTransaction(
723723
void StorageObjectStorage::truncate(
724724
const ASTPtr & /* query */,
725725
const StorageMetadataPtr & /* metadata_snapshot */,
726-
ContextPtr /* context */,
726+
ContextPtr local_context,
727727
TableExclusiveLockHolder & /* table_holder */)
728728
{
729729
const auto path = configuration->getRawPath();
@@ -737,8 +737,12 @@ void StorageObjectStorage::truncate(
737737

738738
if (configuration->isDataLakeConfiguration())
739739
{
740-
throw Exception(ErrorCodes::NOT_IMPLEMENTED,
741-
"Truncate is not supported for data lake engine");
740+
auto * data_lake_metadata = getExternalMetadata(local_context);
741+
if (!data_lake_metadata || !data_lake_metadata->supportsTruncate())
742+
throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Truncate is not supported for this data lake engine");
743+
744+
data_lake_metadata->truncate(local_context, catalog, getStorageID());
745+
return;
742746
}
743747

744748
if (path.hasGlobsIgnorePlaceholders())
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python3
2+
3+
from pyiceberg.catalog import load_catalog
4+
from helpers.config_cluster import minio_secret_key, minio_access_key
5+
import uuid
6+
import pyarrow as pa
7+
from pyiceberg.schema import Schema, NestedField
8+
from pyiceberg.types import LongType, StringType
9+
from pyiceberg.partitioning import PartitionSpec
10+
11+
BASE_URL_LOCAL_RAW = "http://localhost:8182"
12+
CATALOG_NAME = "demo"
13+
14+
def load_catalog_impl(started_cluster):
15+
return load_catalog(
16+
CATALOG_NAME,
17+
**{
18+
"uri": BASE_URL_LOCAL_RAW,
19+
"type": "rest",
20+
"s3.endpoint": f"http://{started_cluster.get_instance_ip('minio')}:9000",
21+
"s3.access-key-id": minio_access_key,
22+
"s3.secret-access-key": minio_secret_key,
23+
},
24+
)
25+
26+
27+
def test_iceberg_truncate_restart(started_cluster_iceberg_no_spark):
28+
instance = started_cluster_iceberg_no_spark.instances["node1"]
29+
catalog = load_catalog_impl(started_cluster_iceberg_no_spark)
30+
31+
namespace = f"clickhouse_truncate_restart_{uuid.uuid4().hex}"
32+
catalog.create_namespace(namespace)
33+
34+
schema = Schema(
35+
NestedField(field_id=1, name="id", field_type=LongType(), required=False),
36+
NestedField(field_id=2, name="val", field_type=StringType(), required=False),
37+
)
38+
table_name = "test_truncate_restart"
39+
catalog.create_table(
40+
identifier=f"{namespace}.{table_name}",
41+
schema=schema,
42+
location=f"s3://warehouse-rest/{namespace}.{table_name}",
43+
partition_spec=PartitionSpec(),
44+
)
45+
46+
ch_table_identifier = f"`{namespace}.{table_name}`"
47+
48+
instance.query(f"DROP DATABASE IF EXISTS {namespace}")
49+
instance.query(
50+
f"""
51+
CREATE DATABASE {namespace} ENGINE = DataLakeCatalog('http://rest:8181/v1', 'minio', '{minio_secret_key}')
52+
SETTINGS
53+
catalog_type='rest',
54+
warehouse='demo',
55+
storage_endpoint='http://minio:9000/warehouse-rest';
56+
""",
57+
settings={"allow_database_iceberg": 1}
58+
)
59+
60+
# 1. Insert initial data and truncate
61+
df = pa.Table.from_pylist([{"id": 1, "val": "A"}, {"id": 2, "val": "B"}])
62+
catalog.load_table(f"{namespace}.{table_name}").append(df)
63+
64+
assert int(instance.query(f"SELECT count() FROM {namespace}.{ch_table_identifier}").strip()) == 2
65+
66+
instance.query(
67+
f"TRUNCATE TABLE {namespace}.{ch_table_identifier}",
68+
settings={"allow_experimental_insert_into_iceberg": 1}
69+
)
70+
assert int(instance.query(f"SELECT count() FROM {namespace}.{ch_table_identifier}").strip()) == 0
71+
72+
# 2. Restart ClickHouse and verify table is still readable (count = 0)
73+
instance.restart_clickhouse()
74+
assert int(instance.query(f"SELECT count() FROM {namespace}.{ch_table_identifier}").strip()) == 0
75+
76+
# 3. Insert new data after restart and verify it's readable
77+
new_df = pa.Table.from_pylist([{"id": 3, "val": "C"}])
78+
catalog.load_table(f"{namespace}.{table_name}").append(new_df)
79+
assert int(instance.query(f"SELECT count() FROM {namespace}.{ch_table_identifier}").strip()) == 1
80+
81+
instance.query(f"DROP DATABASE {namespace}")

0 commit comments

Comments
 (0)