This is an automated email from the ASF dual-hosted git repository.
ayushtkn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git
The following commit(s) were added to refs/heads/master by this push:
new 56928052cc4 HIVE-29535: Iceberg: [V3] Add support for Table Level
Encryption (#6396)
56928052cc4 is described below
commit 56928052cc415a009f98ac6ad50a79622d558fcd
Author: Ayush Saxena <[email protected]>
AuthorDate: Mon Jul 27 20:20:44 2026 +0530
HIVE-29535: Iceberg: [V3] Add support for Table Level Encryption (#6396)
---
.../iceberg/hive/HMSTablePropertyHelper.java | 44 +++
.../java/org/apache/iceberg/hive/HiveCatalog.java | 20 +-
.../apache/iceberg/hive/HiveTableOperations.java | 243 ++++++++++++-
.../iceberg/hive/StagingTableOperations.java | 4 +-
.../apache/iceberg/hive/TestHiveCommitLocks.java | 2 +
.../mr/hive/vector/HiveVectorizedReader.java | 29 +-
.../iceberg/mr/mapreduce/IcebergRecordReader.java | 4 +-
.../apache/iceberg/parquet/HiveParquetUtil.java | 77 +++++
.../parquet/ParquetFooterInputFromCache.java | 71 ++--
.../iceberg/mr/hive/TestHiveCatalogEncryption.java | 380 +++++++++++++++++++++
.../apache/iceberg/mr/hive/test/TestTables.java | 4 +-
.../mr/hive/vector/TestHiveVectorizedReader.java | 6 +-
.../parquet/TestParquetFooterInputFromCache.java | 216 ++++++++++++
.../src/main/java/org/apache/iceberg/DVUtil.java | 214 ++++++++++++
.../apache/iceberg/deletes/BaseDVFileWriter.java | 240 +++++++++++++
.../java/org/apache/iceberg/deletes/Deletes.java | 242 +++++++++++++
16 files changed, 1745 insertions(+), 51 deletions(-)
diff --git
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java
index 52d9ac84821..0639e50debe 100644
---
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java
+++
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java
@@ -19,6 +19,11 @@
package org.apache.iceberg.hive;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -27,6 +32,7 @@
import org.apache.hadoop.hive.common.StatsSetupConst;
import org.apache.hadoop.hive.metastore.api.Table;
import org.apache.hadoop.hive.metastore.api.hive_metastoreConstants;
+import org.apache.hive.iceberg.com.fasterxml.jackson.core.JsonGenerator;
import
org.apache.hive.iceberg.com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.iceberg.BaseMetastoreTableOperations;
import org.apache.iceberg.PartitionSpec;
@@ -38,12 +44,14 @@
import org.apache.iceberg.SortOrder;
import org.apache.iceberg.SortOrderParser;
import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.TableProperties;
import
org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting;
import org.apache.iceberg.relocated.com.google.common.collect.BiMap;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableBiMap;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.util.HashWriter;
import org.apache.iceberg.util.JsonUtil;
import org.apache.iceberg.view.ViewMetadata;
import org.apache.parquet.Strings;
@@ -118,6 +126,7 @@ public static void updateHmsTableForIcebergTable(
metadata.schema(),
maxHiveTablePropertySize);
setStorageHandler(parameters, hiveEngineEnabled);
+ setMetadataHash(metadata, parameters);
// Set the basic statistics
if (summary.get(SnapshotSummary.TOTAL_DATA_FILES_PROP) != null) {
@@ -300,4 +309,39 @@ private static void setField(
private static boolean exposeInHmsProperties(long maxHiveTablePropertySize) {
return maxHiveTablePropertySize > 0;
}
+
+ @VisibleForTesting
+ static void setMetadataHash(TableMetadata metadata, Map<String, String>
parameters) {
+ if (parameters.containsKey(TableProperties.ENCRYPTION_TABLE_KEY)) {
+ byte[] currentHashBytes = hashOf(metadata);
+ parameters.put(
+ BaseMetastoreTableOperations.METADATA_HASH_PROP,
+ Base64.getEncoder().encodeToString(currentHashBytes));
+ }
+ }
+
+ @VisibleForTesting
+ static void verifyMetadataHash(TableMetadata metadata, String
metadataHashFromHMS) {
+ byte[] currentHashBytes = hashOf(metadata);
+ byte[] expectedHashBytes = Base64.getDecoder().decode(metadataHashFromHMS);
+
+ if (!Arrays.equals(expectedHashBytes, currentHashBytes)) {
+ throw new RuntimeException(
+ String.format(
+ "The current metadata file %s might have been modified. Hash of
metadata loaded from storage differs " +
+ "from HMS-stored metadata hash.",
+ metadata.metadataFileLocation()));
+ }
+ }
+
+ private static byte[] hashOf(TableMetadata tableMetadata) {
+ try (HashWriter hashWriter = new HashWriter("SHA-256",
StandardCharsets.UTF_8)) {
+ JsonGenerator generator = JsonUtil.factory().createGenerator(hashWriter);
+ TableMetadataParser.toJson(tableMetadata, generator);
+ generator.flush();
+ return hashWriter.getHash();
+ } catch (NoSuchAlgorithmException | IOException e) {
+ throw new RuntimeException("Unable to produce hash of table metadata",
e);
+ }
+ }
}
diff --git
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
index 24b962738c5..b7bd371dfe1 100644
---
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
+++
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
@@ -19,6 +19,7 @@
package org.apache.iceberg.hive;
+import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -46,6 +47,8 @@
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.encryption.EncryptionUtil;
+import org.apache.iceberg.encryption.KeyManagementClient;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
import org.apache.iceberg.exceptions.NoSuchIcebergViewException;
@@ -100,6 +103,7 @@ public class HiveCatalog extends BaseMetastoreViewCatalog
private String name;
private Configuration conf;
private FileIO fileIO;
+ private KeyManagementClient keyManagementClient;
private ClientPool<IMetaStoreClient, TException> clients;
private boolean listAllTables = false;
private boolean uniqueTableLocation;
@@ -147,6 +151,11 @@ public void initialize(String inputName, Map<String,
String> properties) {
CatalogProperties.UNIQUE_TABLE_LOCATION,
CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT);
+ if (catalogProperties.containsKey(CatalogProperties.ENCRYPTION_KMS_TYPE) ||
+ catalogProperties.containsKey(CatalogProperties.ENCRYPTION_KMS_IMPL)) {
+ this.keyManagementClient = EncryptionUtil.createKmsClient(properties);
+ }
+
this.clients = new CachedClientPool(conf, properties);
}
@@ -684,7 +693,7 @@ private boolean isValidateNamespace(Namespace namespace) {
public TableOperations newTableOps(TableIdentifier tableIdentifier) {
String dbName = tableIdentifier.namespace().level(0);
String tableName = tableIdentifier.name();
- return new HiveTableOperations(conf, clients, fileIO, name, dbName,
tableName);
+ return new HiveTableOperations(conf, clients, fileIO, keyManagementClient,
name, dbName, tableName);
}
@Override
@@ -819,6 +828,15 @@ public Map<String, String> properties() {
return catalogProperties == null ? ImmutableMap.of() : catalogProperties;
}
+ @Override
+ public void close() throws IOException {
+ super.close();
+
+ if (keyManagementClient != null) {
+ keyManagementClient.close();
+ }
+ }
+
@VisibleForTesting
void setListAllTables(boolean listAllTables) {
this.listAllTables = listAllTables;
diff --git
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java
index 64e1a075f46..49ddf140bf9 100644
---
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java
+++
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java
@@ -23,8 +23,10 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hive.common.StatsSetupConst;
import org.apache.hadoop.hive.metastore.IMetaStoreClient;
@@ -35,9 +37,19 @@
import org.apache.hadoop.hive.metastore.api.Table;
import org.apache.iceberg.BaseMetastoreOperations;
import org.apache.iceberg.BaseMetastoreTableOperations;
+import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.ClientPool;
+import org.apache.iceberg.LocationProviders;
import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.encryption.EncryptedKey;
+import org.apache.iceberg.encryption.EncryptingFileIO;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.encryption.EncryptionUtil;
+import org.apache.iceberg.encryption.KeyManagementClient;
+import org.apache.iceberg.encryption.PlaintextEncryptionManager;
+import org.apache.iceberg.encryption.StandardEncryptionManager;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.CommitStateUnknownException;
@@ -45,6 +57,10 @@
import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.hadoop.ConfigProperties;
import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.iceberg.util.PropertyUtil;
import org.apache.thrift.TException;
import org.slf4j.Logger;
@@ -70,18 +86,28 @@ public class HiveTableOperations extends
BaseMetastoreTableOperations
private final long maxHiveTablePropertySize;
private final int metadataRefreshMaxRetries;
private final FileIO fileIO;
+ private final KeyManagementClient keyManagementClient;
private final ClientPool<IMetaStoreClient, TException> metaClients;
+ private EncryptionManager encryptionManager;
+ private EncryptingFileIO encryptingFileIO;
+ private String tableKeyId;
+ private int encryptionDekLength;
+
+ private List<EncryptedKey> encryptedKeys = List.of();
+
protected HiveTableOperations(
Configuration conf,
ClientPool<IMetaStoreClient, TException> metaClients,
FileIO fileIO,
+ KeyManagementClient keyManagementClient,
String catalogName,
String database,
String table) {
this.conf = conf;
this.metaClients = metaClients;
this.fileIO = fileIO;
+ this.keyManagementClient = keyManagementClient;
this.fullName = catalogName + "." + database + "." + table;
this.catalogName = catalogName;
this.database = database;
@@ -99,7 +125,7 @@ protected HiveTableOperations(
* Used by HiveTransaction to defer HMS updates to coordinator for atomic
batch commits.
*/
public StagingTableOperations toStagingOps() {
- return new StagingTableOperations(conf, metaClients, fileIO, catalogName,
database, tableName);
+ return new StagingTableOperations(conf, metaClients, fileIO,
keyManagementClient, catalogName, database, tableName);
}
@Override
@@ -109,12 +135,49 @@ protected String tableName() {
@Override
public FileIO io() {
- return fileIO;
+ if (tableKeyId == null) {
+ return fileIO;
+ }
+
+ if (encryptingFileIO == null) {
+ encryptingFileIO = EncryptingFileIO.combine(fileIO, encryption());
+ }
+
+ return encryptingFileIO;
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ if (encryptionManager != null) {
+ return encryptionManager;
+ }
+
+ if (tableKeyId != null) {
+ Preconditions.checkArgument(
+ keyManagementClient != null,
+ "Cannot create encryption manager without a key management client. "
+
+ "Consider setting the '%s' catalog property",
+ CatalogProperties.ENCRYPTION_KMS_IMPL);
+
+ Map<String, String> encryptionProperties =
+ ImmutableMap.of(
+ TableProperties.ENCRYPTION_TABLE_KEY, tableKeyId,
+ TableProperties.ENCRYPTION_DEK_LENGTH,
String.valueOf(encryptionDekLength));
+ encryptionManager =
+ EncryptionUtil.createEncryptionManager(encryptedKeys,
encryptionProperties, keyManagementClient);
+ } else {
+ return PlaintextEncryptionManager.instance();
+ }
+
+ return encryptionManager;
}
@Override
protected void doRefresh() {
String metadataLocation = null;
+ String tableKeyIdFromHMS = null;
+ String dekLengthFromHMS = null;
+ String metadataHashFromHMS = null;
try {
Table table = metaClients.run(client -> client.getTable(database,
tableName));
@@ -124,7 +187,9 @@ protected void doRefresh() {
HiveOperationsBase.validateTableIsIceberg(table, fullName);
metadataLocation = table.getParameters().get(METADATA_LOCATION_PROP);
-
+ tableKeyIdFromHMS =
table.getParameters().get(TableProperties.ENCRYPTION_TABLE_KEY);
+ dekLengthFromHMS =
table.getParameters().get(TableProperties.ENCRYPTION_DEK_LENGTH);
+ metadataHashFromHMS = table.getParameters().get(METADATA_HASH_PROP);
} catch (NoSuchObjectException e) {
if (currentMetadataLocation() != null) {
throw new NoSuchTableException("No such table: %s.%s", database,
tableName);
@@ -141,21 +206,67 @@ protected void doRefresh() {
}
refreshFromMetadataLocation(metadataLocation, metadataRefreshMaxRetries);
+ if (tableKeyIdFromHMS != null) {
+ tableKeyId = tableKeyIdFromHMS;
+ encryptionDekLength = (dekLengthFromHMS != null) ?
+ Integer.parseInt(dekLengthFromHMS) :
+ TableProperties.ENCRYPTION_DEK_LENGTH_DEFAULT;
+
+ if (current() != null) {
+ checkIntegrityForEncryption(tableKeyIdFromHMS, dekLengthFromHMS,
metadataHashFromHMS);
+
+ encryptedKeys =
+ Optional.ofNullable(current().encryptionKeys())
+ .map(Lists::newLinkedList)
+ .orElseGet(Lists::newLinkedList);
+
+ if (encryptionManager != null) {
+ Set<String> keyIdsFromMetadata =
+
encryptedKeys.stream().map(EncryptedKey::keyId).collect(Collectors.toSet());
+
+ for (EncryptedKey keyFromEM :
+ EncryptionUtil.encryptionKeys(encryptionManager).values()) {
+ if (!keyIdsFromMetadata.contains(keyFromEM.keyId())) {
+ encryptedKeys.add(keyFromEM);
+ }
+ }
+ }
+ }
+
+ // Force re-creation of encryption manager with updated keys
+ encryptingFileIO = null;
+ encryptionManager = null;
+ }
}
@SuppressWarnings({"checkstyle:CyclomaticComplexity", "MethodLength"})
@Override
protected void doCommit(TableMetadata base, TableMetadata metadata) {
boolean newTable = base == null;
- String newMetadataLocation = writeNewMetadataIfRequired(newTable,
metadata);
- boolean hiveEngineEnabled = hiveEngineEnabled(metadata, conf);
+ encryptionPropsFromMetadata(metadata.properties());
boolean keepHiveStats = conf.getBoolean(ConfigProperties.KEEP_HIVE_STATS,
false);
+ final TableMetadata tableMetadata;
+ EncryptionManager encrManager = encryption();
+ if (encrManager instanceof StandardEncryptionManager) {
+ // Add new encryption keys to the metadata
+ TableMetadata.Builder builder = TableMetadata.buildFrom(metadata);
+ for (Map.Entry<String, EncryptedKey> entry :
EncryptionUtil.encryptionKeys(encrManager).entrySet()) {
+ builder.addEncryptionKey(entry.getValue());
+ }
+
+ tableMetadata = builder.build();
+ } else {
+ tableMetadata = metadata;
+ }
+
+ boolean hiveEngineEnabled = hiveEngineEnabled(tableMetadata, conf);
+ String newMetadataLocation = writeNewMetadataIfRequired(newTable,
tableMetadata);
BaseMetastoreOperations.CommitStatus commitStatus =
BaseMetastoreOperations.CommitStatus.FAILURE;
boolean updateHiveTable = false;
- HiveLock lock = lockObject(base != null ? base : metadata);
+ HiveLock lock = lockObject(base != null ? base : tableMetadata);
try {
lock.lock();
@@ -175,7 +286,7 @@ protected void doCommit(TableMetadata base, TableMetadata
metadata) {
LOG.debug("Committing existing table: {}", fullName);
} else {
tbl = newHmsTable(
- metadata.property(HiveCatalog.HMS_TABLE_OWNER,
HiveHadoopUtil.currentUser())
+ tableMetadata.property(HiveCatalog.HMS_TABLE_OWNER,
HiveHadoopUtil.currentUser())
);
LOG.debug("Committing new table: {}", fullName);
}
@@ -192,8 +303,8 @@ protected void doCommit(TableMetadata base, TableMetadata
metadata) {
tbl.setSd(
HiveOperationsBase.storageDescriptor(
- metadata.schema(),
- metadata.location(),
+ tableMetadata.schema(),
+ tableMetadata.location(),
hiveEngineEnabled, // set to pickup any schema changes
bucketCols,
numBuckets));
@@ -212,14 +323,24 @@ protected void doCommit(TableMetadata base, TableMetadata
metadata) {
if (base != null) {
removedProps =
base.properties().keySet().stream()
- .filter(key -> !metadata.properties().containsKey(key))
+ .filter(key -> !tableMetadata.properties().containsKey(key))
.collect(Collectors.toSet());
+
+ Preconditions.checkArgument(
+ !removedProps.contains(TableProperties.ENCRYPTION_TABLE_KEY),
+ "Cannot remove key ID from an encrypted table");
+
+ Preconditions.checkArgument(
+ Objects.equals(
+ base.properties().get(TableProperties.ENCRYPTION_TABLE_KEY),
+
metadata.properties().get(TableProperties.ENCRYPTION_TABLE_KEY)),
+ "Cannot modify key ID of an encrypted table");
}
HMSTablePropertyHelper.updateHmsTableForIcebergTable(
newMetadataLocation,
tbl,
- metadata,
+ tableMetadata,
removedProps,
hiveEngineEnabled,
maxHiveTablePropertySize,
@@ -273,7 +394,7 @@ protected void doCommit(TableMetadata base, TableMetadata
metadata) {
// issue for example, and triggers this exception. So we need
double-check to make sure
// this is really a concurrent modification. Hitting this exception
means no pending
// requests, if any, can succeed later, so it's safe to check status
in strict mode
- commitStatus = checkCommitStatusStrict(newMetadataLocation,
metadata);
+ commitStatus = checkCommitStatusStrict(newMetadataLocation,
tableMetadata);
if (commitStatus == BaseMetastoreOperations.CommitStatus.FAILURE) {
throw new CommitFailedException(
e, "The table %s.%s has been modified concurrently", database,
tableName);
@@ -284,7 +405,7 @@ protected void doCommit(TableMetadata base, TableMetadata
metadata) {
database,
tableName,
e);
- commitStatus = checkCommitStatus(newMetadataLocation, metadata);
+ commitStatus = checkCommitStatus(newMetadataLocation, tableMetadata);
}
switch (commitStatus) {
@@ -340,6 +461,52 @@ public ClientPool<IMetaStoreClient, TException>
metaClients() {
return metaClients;
}
+ @Override
+ public TableOperations temp(TableMetadata uncommittedMetadata) {
+ return new TableOperations() {
+ @Override
+ public TableMetadata current() {
+ return uncommittedMetadata;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ throw new UnsupportedOperationException("Cannot call refresh on
temporary table operations");
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata metadata) {
+ throw new UnsupportedOperationException("Cannot call commit on
temporary table operations");
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return
HiveTableOperations.this.metadataFileLocation(uncommittedMetadata, fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ return LocationProviders.locationsFor(uncommittedMetadata.location(),
uncommittedMetadata.properties());
+ }
+
+ @Override
+ public FileIO io() {
+
HiveTableOperations.this.encryptionPropsFromMetadata(uncommittedMetadata.properties());
+ return HiveTableOperations.this.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return HiveTableOperations.this.encryption();
+ }
+
+ @Override
+ public long newSnapshotId() {
+ return HiveTableOperations.this.newSnapshotId();
+ }
+ };
+ }
+
/**
* Returns if the hive engine related values should be enabled on the table,
or not.
*
@@ -398,6 +565,56 @@ public static boolean hiveLockEnabled(Map<String, String>
properties, Configurat
ConfigProperties.LOCK_HIVE_ENABLED,
TableProperties.HIVE_LOCK_ENABLED_DEFAULT);
}
+ private void encryptionPropsFromMetadata(Map<String, String>
tableProperties) {
+ if (tableKeyId == null) {
+ tableKeyId = tableProperties.get(TableProperties.ENCRYPTION_TABLE_KEY);
+ }
+
+ if (tableKeyId != null && encryptionDekLength <= 0) {
+ encryptionDekLength =
+ PropertyUtil.propertyAsInt(
+ tableProperties,
+ TableProperties.ENCRYPTION_DEK_LENGTH,
+ TableProperties.ENCRYPTION_DEK_LENGTH_DEFAULT);
+ }
+ }
+
+ private void checkIntegrityForEncryption(
+ String encryptionKeyIdFromHMS, String dekLengthFromHMS, String
metadataHashFromHMS) {
+ TableMetadata metadata = current();
+ if (StringUtils.isNotEmpty(metadataHashFromHMS)) {
+ HMSTablePropertyHelper.verifyMetadataHash(metadata, metadataHashFromHMS);
+ return;
+ }
+
+ LOG.warn(
+ "Full metadata integrity check skipped because no metadata hash was
recorded in HMS for table {}." +
+ " Falling back to encryption property based check.",
+ tableName);
+
+ Map<String, String> propertiesFromMetadata = metadata.properties();
+
+ String encryptionKeyIdFromMetadata =
+ propertiesFromMetadata.get(TableProperties.ENCRYPTION_TABLE_KEY);
+ if (!Objects.equals(encryptionKeyIdFromHMS, encryptionKeyIdFromMetadata)) {
+ String errMsg =
+ String.format(
+ "Metadata file might have been modified. Encryption key id %s
differs from HMS value %s",
+ encryptionKeyIdFromMetadata, encryptionKeyIdFromHMS);
+ throw new RuntimeException(errMsg);
+ }
+
+ String dekLengthFromMetadata =
+ propertiesFromMetadata.get(TableProperties.ENCRYPTION_DEK_LENGTH);
+ if (!Objects.equals(dekLengthFromHMS, dekLengthFromMetadata)) {
+ String errMsg =
+ String.format(
+ "Metadata file might have been modified. DEK length %s differs
from HMS value %s",
+ dekLengthFromMetadata, dekLengthFromHMS);
+ throw new RuntimeException(errMsg);
+ }
+ }
+
HiveLock lockObject(TableMetadata metadata) {
if (hiveLockEnabled(metadata, conf)) {
return new MetastoreLock(conf, metaClients, catalogName, database,
tableName);
diff --git
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/StagingTableOperations.java
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/StagingTableOperations.java
index c61bde44d65..7715b122677 100644
---
a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/StagingTableOperations.java
+++
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/StagingTableOperations.java
@@ -23,6 +23,7 @@
import org.apache.hadoop.hive.metastore.IMetaStoreClient;
import org.apache.iceberg.ClientPool;
import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.encryption.KeyManagementClient;
import org.apache.iceberg.io.FileIO;
import org.apache.thrift.TException;
@@ -38,10 +39,11 @@ public StagingTableOperations(
Configuration conf,
ClientPool<IMetaStoreClient, TException> metaClients,
FileIO fileIO,
+ KeyManagementClient keyManagementClient,
String catalogName,
String database,
String table) {
- super(conf, metaClients, fileIO, catalogName, database, table);
+ super(conf, metaClients, fileIO, keyManagementClient, catalogName,
database, table);
}
@Override
diff --git
a/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/TestHiveCommitLocks.java
b/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/TestHiveCommitLocks.java
index d36f41b191e..fb08127683b 100644
---
a/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/TestHiveCommitLocks.java
+++
b/iceberg/iceberg-catalog/src/test/java/org/apache/iceberg/hive/TestHiveCommitLocks.java
@@ -187,6 +187,7 @@ public void before() throws Exception {
overriddenHiveConf,
spyCachedClientPool,
ops.io(),
+ null,
catalog.name(),
dbName,
tableName));
@@ -608,6 +609,7 @@ public void testNoLockCallsWithNoLock() throws TException {
confWithLock,
spyCachedClientPool,
ops.io(),
+ null,
catalog.name(),
TABLE_IDENTIFIER.namespace().level(0),
TABLE_IDENTIFIER.name()));
diff --git
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/vector/HiveVectorizedReader.java
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/vector/HiveVectorizedReader.java
index 4189b3aea20..bfc7cc1fc5b 100644
---
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/vector/HiveVectorizedReader.java
+++
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/hive/vector/HiveVectorizedReader.java
@@ -51,15 +51,19 @@
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
+import org.apache.iceberg.data.CachingDeleteLoader;
+import org.apache.iceberg.data.DeleteLoader;
+import org.apache.iceberg.encryption.EncryptingFileIO;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.hive.HiveSchemaUtil;
import org.apache.iceberg.io.CloseableIterable;
import org.apache.iceberg.io.CloseableIterator;
+import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.mr.InputFormatConfig;
import org.apache.iceberg.mr.hive.HiveIcebergInputFormat;
import org.apache.iceberg.mr.mapred.MapredIcebergInputFormat;
import org.apache.iceberg.orc.VectorizedReadUtils;
-import org.apache.iceberg.parquet.ParquetFooterInputFromCache;
+import org.apache.iceberg.parquet.HiveParquetUtil;
import org.apache.iceberg.parquet.ParquetSchemaUtil;
import org.apache.iceberg.parquet.TypeWithSchemaVisitor;
import org.apache.iceberg.parquet.VariantParquetFilters;
@@ -67,8 +71,6 @@
import org.apache.iceberg.relocated.com.google.common.collect.Maps;
import org.apache.iceberg.types.Types;
import org.apache.orc.impl.OrcTail;
-import org.apache.parquet.format.converter.ParquetMetadataConverter;
-import org.apache.parquet.hadoop.ParquetFileReader;
import org.apache.parquet.hadoop.metadata.ParquetMetadata;
import org.apache.parquet.schema.MessageType;
@@ -91,8 +93,16 @@ public static CloseableIterable<HiveBatchContext>
reader(Table table, Path path,
Schema requiredSchema = readSchema;
if (!task.deletes().isEmpty()) {
- deleteFilter = new HiveDeleteFilter(table.io(), task, table.schema(),
prepareSchemaForDeleteFilter(readSchema),
- context.getConfiguration());
+ deleteFilter =
+ new HiveDeleteFilter(EncryptingFileIO.combine(table.io(),
table.encryption()), task, table.schema(),
+ prepareSchemaForDeleteFilter(readSchema),
context.getConfiguration()) {
+ @Override
+ protected DeleteLoader newDeleteLoader() {
+ return new CachingDeleteLoader(
+ deleteFile -> EncryptingFileIO.combine(table.io(),
table.encryption()).newInputFile(deleteFile),
+ context.getConfiguration());
+ }
+ };
requiredSchema = deleteFilter.requiredSchema();
// TODO: take requiredSchema and adjust readColumnIds below accordingly
for equality delete cases
// and remove below limitation
@@ -166,7 +176,8 @@ public static CloseableIterable<HiveBatchContext>
reader(Table table, Path path,
case PARQUET:
recordReader = parquetRecordReader(job, reporter, task, path, start,
length, fileId,
getInitialColumnDefaults(table.schema().columns()),
- HiveIcebergInputFormat.residualForReaderPruning(task, job));
+ HiveIcebergInputFormat.residualForReaderPruning(task, job),
+ table.io());
break;
default:
throw new UnsupportedOperationException("Vectorized Hive reading
unimplemented for format: " + format);
@@ -242,7 +253,7 @@ private static RecordReader<NullWritable,
VectorizedRowBatch> orcRecordReader(Jo
private static RecordReader<NullWritable, VectorizedRowBatch>
parquetRecordReader(JobConf job, Reporter reporter,
FileScanTask task, Path path, long start, long length, SyntheticFileId
fileId,
- Map<String, Object> initialColumnDefaults, Expression residual) throws
IOException {
+ Map<String, Object> initialColumnDefaults, Expression residual, FileIO
io) throws IOException {
InputSplit split = new FileSplit(path, start, length, job);
VectorizedParquetInputFormat inputFormat = new
VectorizedParquetInputFormat();
@@ -253,9 +264,7 @@ private static RecordReader<NullWritable,
VectorizedRowBatch> parquetRecordReade
footerData = LlapProxy.getIo().getParquetFooterBuffersFromCache(path,
job, fileId);
}
- ParquetMetadata parquetMetadata = footerData != null ?
- ParquetFileReader.readFooter(new
ParquetFooterInputFromCache(footerData), ParquetMetadataConverter.NO_FILTER) :
- ParquetFileReader.readFooter(job, path);
+ ParquetMetadata parquetMetadata = HiveParquetUtil.readFooter(task.file(),
io, job, footerData);
MessageType fileSchema = parquetMetadata.getFileMetaData().getSchema();
ParquetMetadata prunedMetadata =
VariantParquetFilters.pruneVariantRowGroups(parquetMetadata,
fileSchema, residual);
diff --git
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergRecordReader.java
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergRecordReader.java
index 96f8705a3f7..635a311a6c0 100644
---
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergRecordReader.java
+++
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergRecordReader.java
@@ -52,6 +52,7 @@
import org.apache.iceberg.data.orc.GenericOrcReader;
import org.apache.iceberg.data.parquet.GenericParquetReaders;
import org.apache.iceberg.encryption.EncryptedFiles;
+import org.apache.iceberg.encryption.EncryptingFileIO;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.hive.HiveSchemaUtil;
import org.apache.iceberg.io.CloseableIterable;
@@ -141,7 +142,8 @@ private CloseableIterable<T> open(FileScanTask currentTask,
Schema readSchema) {
DeleteFilter<Record> deletes = new GenericDeleteFilter(table.io(),
currentTask, table.schema(), readSchema) {
@Override
protected DeleteLoader newDeleteLoader() {
- return new CachingDeleteLoader(this::loadInputFile, conf);
+ return new CachingDeleteLoader(
+ deleteFile -> EncryptingFileIO.combine(table.io(),
table.encryption()).newInputFile(deleteFile), conf);
}
};
Schema requiredSchema = deletes.requiredSchema();
diff --git
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/HiveParquetUtil.java
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/HiveParquetUtil.java
new file mode 100644
index 00000000000..0b97927e9e4
--- /dev/null
+++
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/HiveParquetUtil.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.parquet;
+
+import java.io.IOException;
+import org.apache.hadoop.hive.common.io.encoded.MemoryBufferOrBuffers;
+import org.apache.hadoop.mapred.JobConf;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.encryption.NativeEncryptionInputFile;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.util.ByteBuffers;
+import org.apache.parquet.HadoopReadOptions;
+import org.apache.parquet.ParquetReadOptions;
+import org.apache.parquet.crypto.FileDecryptionProperties;
+import org.apache.parquet.format.converter.ParquetMetadataConverter;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetFileWriter;
+import org.apache.parquet.hadoop.metadata.ParquetMetadata;
+
+public class HiveParquetUtil {
+
+ private HiveParquetUtil() {
+ }
+
+ public static ParquetMetadata readFooter(DataFile dataFile, FileIO io,
JobConf job, MemoryBufferOrBuffers footerData)
+ throws IOException {
+
+ InputFile icebergInputFile = io.newInputFile(dataFile);
+ InputFile rawFileToRead = icebergInputFile;
+
+ ParquetReadOptions.Builder optionsBuilder =
+
HadoopReadOptions.builder(job).withMetadataFilter(ParquetMetadataConverter.NO_FILTER);
+
+ if (icebergInputFile instanceof NativeEncryptionInputFile nativeEncFile) {
+ rawFileToRead = nativeEncFile.encryptedInputFile();
+
+ byte[] footerKey =
ByteBuffers.toByteArray(nativeEncFile.keyMetadata().encryptionKey());
+ byte[] aadPrefix =
ByteBuffers.toByteArray(nativeEncFile.keyMetadata().aadPrefix());
+
+ FileDecryptionProperties decryptProps =
+
FileDecryptionProperties.builder().withFooterKey(footerKey).withAADPrefix(aadPrefix).build();
+
+ optionsBuilder.withDecryption(decryptProps);
+ }
+
+ org.apache.parquet.io.InputFile parquetInputFile;
+ if (footerData != null) {
+ byte[] magic = (icebergInputFile instanceof NativeEncryptionInputFile) ?
+ ParquetFileWriter.EFMAGIC : ParquetFileWriter.MAGIC;
+ parquetInputFile = new ParquetFooterInputFromCache(footerData, magic);
+ } else {
+ parquetInputFile = ParquetIO.file(rawFileToRead);
+ }
+
+ try (ParquetFileReader reader = ParquetFileReader.open(parquetInputFile,
optionsBuilder.build())) {
+ return reader.getFooter();
+ }
+ }
+}
diff --git
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/ParquetFooterInputFromCache.java
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/ParquetFooterInputFromCache.java
index 64eae4ccfaf..adea4cfc06d 100644
---
a/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/ParquetFooterInputFromCache.java
+++
b/iceberg/iceberg-handler/src/main/java/org/apache/iceberg/parquet/ParquetFooterInputFromCache.java
@@ -40,8 +40,6 @@
public final class ParquetFooterInputFromCache
extends SeekableInputStream implements InputFile {
public static final int FOOTER_LENGTH_SIZE = 4; // For the file size check.
- private static final int TAIL_LENGTH = ParquetFileWriter.MAGIC.length +
FOOTER_LENGTH_SIZE;
- private static final int FAKE_PREFIX_LENGTH = ParquetFileWriter.MAGIC.length;
private final int length;
private final int footerLength;
private int position = 0;
@@ -51,27 +49,37 @@ public final class ParquetFooterInputFromCache
private final int[] positions;
public ParquetFooterInputFromCache(MemoryBufferOrBuffers footerData) {
+ this(footerData, ParquetFileWriter.MAGIC);
+ }
+
+ public ParquetFooterInputFromCache(MemoryBufferOrBuffers footerData, byte[]
magic) {
MemoryBuffer oneBuffer = footerData.getSingleBuffer();
+ MemoryBuffer[] footerBuffers;
if (oneBuffer != null) {
- cacheData = new MemoryBuffer[2];
- cacheData[0] = oneBuffer;
+ footerBuffers = new MemoryBuffer[] { oneBuffer };
} else {
- MemoryBuffer[] bufs = footerData.getMultipleBuffers();
- cacheData = new MemoryBuffer[bufs.length + 1];
- System.arraycopy(bufs, 0, cacheData, 0, bufs.length);
+ footerBuffers = footerData.getMultipleBuffers();
}
+
+ // Layout: [prefix magic] [footer data buffers...] [tail: footer length +
magic]
+ cacheData = new MemoryBuffer[1 + footerBuffers.length + 1];
+ cacheData[0] = new ByteArrayBuffer(magic);
+ System.arraycopy(footerBuffers, 0, cacheData, 1, footerBuffers.length);
+
int footerLen = 0;
positions = new int[cacheData.length];
- for (int i = 0; i < cacheData.length - 1; ++i) {
- positions[i] = footerLen;
+ positions[0] = 0;
+ positions[1] = magic.length;
+ for (int i = 1; i <= footerBuffers.length; ++i) {
int dataLen = cacheData[i].getByteBufferRaw().remaining();
assert dataLen > 0;
footerLen += dataLen;
+ positions[i + 1] = magic.length + footerLen;
}
- positions[cacheData.length - 1] = footerLen;
- cacheData[cacheData.length - 1] = new FooterEndBuffer(footerLen);
+
+ cacheData[cacheData.length - 1] = new FooterEndBuffer(footerLen, magic);
this.footerLength = footerLen;
- this.length = footerLen + FAKE_PREFIX_LENGTH + TAIL_LENGTH;
+ this.length = magic.length + footerLen + FOOTER_LENGTH_SIZE + magic.length;
}
@Override
@@ -94,17 +102,16 @@ public long getPos() throws IOException {
@Override
public void seek(long pos) throws IOException {
this.position = (int) pos;
- long targetPos = pos - FAKE_PREFIX_LENGTH;
// Not efficient, but we don't expect this to be called frequently.
for (int i = 1; i <= positions.length; ++i) {
- int endPos = (i == positions.length) ? (length - FAKE_PREFIX_LENGTH) :
positions[i];
- if (endPos > targetPos) {
+ int endPos = (i == positions.length) ? length : positions[i];
+ if (endPos > pos) {
bufferIx = i - 1;
- bufferPos = (int) (targetPos - positions[i - 1]);
+ bufferPos = (int) (pos - positions[i - 1]);
return;
}
}
- throw new IOException("Incorrect seek " + targetPos + "; footer length " +
footerLength +
+ throw new IOException("Incorrect seek " + pos + "; footer length " +
footerLength +
Arrays.toString(positions));
}
@@ -138,6 +145,7 @@ public int readInternal(byte[] bytes, int offset, int len) {
}
argPos += toConsume;
}
+ position += len;
return len;
}
@@ -184,6 +192,27 @@ public void readFully(ByteBuffer arg0) throws IOException {
read(arg0);
}
+ /**
+ * Simple MemoryBuffer backed by a byte array.
+ */
+ private static final class ByteArrayBuffer implements MemoryBuffer {
+ private final ByteBuffer bb;
+
+ ByteArrayBuffer(byte[] data) {
+ bb = ByteBuffer.wrap(data);
+ }
+
+ @Override
+ public ByteBuffer getByteBufferRaw() {
+ return bb;
+ }
+
+ @Override
+ public ByteBuffer getByteBufferDup() {
+ return bb.duplicate();
+ }
+ }
+
/**
* The fake buffer that emulates end of file, with footer length and magic.
Given that these
* can be generated based on the footer buffer itself, we don't cache them.
@@ -191,14 +220,14 @@ public void readFully(ByteBuffer arg0) throws IOException
{
private static final class FooterEndBuffer implements MemoryBuffer {
private final ByteBuffer bb;
- FooterEndBuffer(int footerLength) {
- byte[] bytes = new byte[8];
+ FooterEndBuffer(int footerLength, byte[] magic) {
+ byte[] bytes = new byte[FOOTER_LENGTH_SIZE + magic.length];
bytes[0] = (byte) ((footerLength >>> 0) & 0xFF);
bytes[1] = (byte) ((footerLength >>> 8) & 0xFF);
bytes[2] = (byte) ((footerLength >>> 16) & 0xFF);
bytes[3] = (byte) ((footerLength >>> 24) & 0xFF);
- for (int i = 0; i < ParquetFileWriter.MAGIC.length; ++i) {
- bytes[4 + i] = ParquetFileWriter.MAGIC[i];
+ for (int i = 0; i < magic.length; ++i) {
+ bytes[FOOTER_LENGTH_SIZE + i] = magic[i];
}
bb = ByteBuffer.wrap(bytes);
}
diff --git
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestHiveCatalogEncryption.java
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestHiveCatalogEncryption.java
new file mode 100644
index 00000000000..b45df4f53af
--- /dev/null
+++
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/TestHiveCatalogEncryption.java
@@ -0,0 +1,380 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iceberg.mr.hive;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.StreamSupport;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.ChecksumFileSystem;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.iceberg.AppendFiles;
+import org.apache.iceberg.AssertHelpers;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.Transaction;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.encryption.Ciphers;
+import org.apache.iceberg.encryption.UnitestKMS;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.io.SeekableInputStream;
+import org.apache.iceberg.mr.hive.test.TestTables.TestTableType;
+import
org.apache.iceberg.mr.hive.test.utils.HiveIcebergStorageHandlerTestUtils;
+import org.apache.iceberg.parquet.Parquet;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.apache.parquet.crypto.ParquetCryptoRuntimeException;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runners.Parameterized.Parameters;
+
+import static org.apache.iceberg.Files.localInput;
+
+public class TestHiveCatalogEncryption extends
HiveIcebergStorageHandlerWithEngineBase {
+
+ private String encryptionProps() {
+ return String.format(
+ "TBLPROPERTIES ('%s'='%s', '%s'='3', '%s'='%s')",
+ TableProperties.ENCRYPTION_TABLE_KEY, UnitestKMS.MASTER_KEY_NAME1,
+ TableProperties.FORMAT_VERSION,
+ TableProperties.DEFAULT_FILE_FORMAT, fileFormat.toString());
+ }
+
+ @Parameters(name = "fileFormat={0}, catalog={1}, isVectorized={2},
formatVersion={3}")
+ public static Collection<Object[]> parameters() {
+ return HiveIcebergStorageHandlerWithEngineBase.getParameters(p ->
+ p.testTableType() == TestTableType.HIVE_CATALOG && p.formatVersion()
== 3 &&
+ // Iceberg has no native ORC encryption
(org.apache.iceberg.orc.ORC#writeData throws), so ORC is excluded.
+ p.fileFormat() != FileFormat.ORC);
+ }
+
+ @BeforeClass
+ public static void beforeClass() {
+ shell = HiveIcebergStorageHandlerTestUtils.shell(
+ ImmutableMap.of("iceberg.catalog.default_iceberg.encryption.kms-impl",
UnitestKMS.class.getCanonicalName()));
+ }
+
+ @Before
+ public void configureEncryption() {
+
shell.getHiveConf().set("iceberg.catalog.default_iceberg.encryption.kms-impl",
UnitestKMS.class.getCanonicalName());
+ }
+
+ @Test
+ public void testWriteAndReadEncryptedTable() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_v3_table");
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+
+ // Insert initial set of rows
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')",
identifier.name()));
+
+ // Verify all rows were inserted correctly
+ List<Object[]> rows = shell.executeStatement("SELECT * FROM " +
identifier.name() + " ORDER BY id");
+ Assert.assertEquals("Should have 4 rows after insert", 4, rows.size());
+ Assert.assertEquals(1L, rows.get(0)[0]);
+ Assert.assertEquals("a", rows.get(0)[1]);
+ Assert.assertEquals(4L, rows.get(3)[0]);
+ Assert.assertEquals("d", rows.get(3)[1]);
+
+ // Perform the DELETE operation
+ shell.executeStatement(String.format(
+ "DELETE FROM %s WHERE id < 3", identifier.name()));
+
+ // Verify that the rows were actually deleted
+ List<Object[]> rowsAfterDelete = shell.executeStatement("SELECT * FROM " +
identifier.name() + " ORDER BY id");
+ Assert.assertEquals("Should have 2 rows remaining after delete", 2,
rowsAfterDelete.size());
+ Assert.assertEquals(3L, rowsAfterDelete.get(0)[0]);
+ Assert.assertEquals("c", rowsAfterDelete.get(0)[1]);
+ Assert.assertEquals(4L, rowsAfterDelete.get(1)[0]);
+ Assert.assertEquals("d", rowsAfterDelete.get(1)[1]);
+ }
+
+ @Test
+ public void testManifestEncryption() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"manifest_check");
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint) STORED BY iceberg %s",
+ identifier.name(), encryptionProps()));
+ shell.executeStatement("INSERT INTO " + identifier.name() + " VALUES (1)");
+
+ Table table = testTables.loadTable(identifier);
+ Assert.assertEquals(3, ((BaseTable)
table).operations().current().formatVersion());
+
+ table.currentSnapshot().allManifests(table.io()).forEach(manifest -> {
+ try (SeekableInputStream stream =
localInput(manifest.path()).newStream()) {
+ byte[] magic = new byte[4];
+ stream.read(magic);
+ Assert.assertEquals("Should have GCM magic",
+ Ciphers.GCM_STREAM_MAGIC_STRING, new String(magic,
StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testAppendTransaction() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_append_transaction");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+
+ Table table = testTables.loadTable(identifier);
+ org.apache.iceberg.DataFile dataFile =
table.newScan().planFiles().iterator().next().file();
+
+ long initialFiles =
StreamSupport.stream(table.newScan().planFiles().spliterator(), false).count();
+
+ // Start a transaction and append the data file
+ org.apache.iceberg.Transaction transaction = table.newTransaction();
+ org.apache.iceberg.AppendFiles append = transaction.newAppend();
+ append.appendFile(dataFile);
+ append.commit();
+ transaction.commitTransaction();
+
+ table.refresh();
+ long finalFiles =
StreamSupport.stream(table.newScan().planFiles().spliterator(), false).count();
+
+ Assert.assertEquals("Should have added 1 data file via transaction",
+ initialFiles + 1, finalFiles);
+ }
+
+ @Test
+ public void testConcurrentAppendTransactions() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_concurrent_append");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+
+ Table table = testTables.loadTable(identifier);
+ org.apache.iceberg.DataFile dataFile =
table.newScan().planFiles().iterator().next().file();
+
+ long initialFiles = java.util.stream.StreamSupport
+ .stream(table.newScan().planFiles().spliterator(), false).count();
+
+ // Start the first transaction
+ Transaction transaction = table.newTransaction();
+ AppendFiles append = transaction.newAppend();
+ append.appendFile(dataFile);
+
+ // Concurrently append to the table before committing the first transaction
+ // using a separate table instance load
+
testTables.loadTable(identifier).newFastAppend().appendFile(dataFile).commit();
+
+ // Now commit the first transaction
+ append.commit();
+ transaction.commitTransaction();
+
+ table.refresh();
+ long finalFiles =
StreamSupport.stream(table.newScan().planFiles().spliterator(), false).count();
+
+ Assert.assertEquals("Should have added 2 data files total via concurrent
transactions",
+ initialFiles + 2, finalFiles);
+ }
+
+ @Test
+ public void testConcurrentReplaceTransactions() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_concurrent_replace");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+
+ Table table = testTables.loadTable(identifier);
+ DataFile dataFile = table.newScan().planFiles().iterator().next().file();
+
+ Transaction firstReplace = table.newTransaction();
+ firstReplace.newOverwrite()
+ .overwriteByRowFilter(Expressions.alwaysTrue())
+ .addFile(dataFile)
+ .commit();
+
+ org.apache.iceberg.Transaction secondReplace = table.newTransaction();
+ secondReplace.newOverwrite()
+ .overwriteByRowFilter(Expressions.alwaysTrue())
+ .addFile(dataFile)
+ .commit();
+
+ // Commit the first replacement
+ firstReplace.commitTransaction();
+
+ // Commit the second replacement
+ secondReplace.commitTransaction();
+
+ table.refresh();
+ long finalFiles =
StreamSupport.stream(table.newScan().planFiles().spliterator(), false).count();
+
+ // Since both were full table replace/overwrites with a single file, the
final count should be exactly 1
+ Assert.assertEquals("Should only have 1 data file after concurrent replace
transactions",
+ 1, finalFiles);
+ }
+
+ @Test
+ public void testRefresh() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_refresh_table");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+
+ // Load the table and check current snapshot
+ Table table = testTables.loadTable(identifier);
+ long snapshotIdBefore = table.currentSnapshot().snapshotId();
+
+ // Insert more data via Hive
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (2, 'b')", identifier.name()));
+
+ // Refresh the table object and verify the snapshot has updated
+ table.refresh();
+ long snapshotIdAfter = table.currentSnapshot().snapshotId();
+
+ Assert.assertNotEquals("Snapshot ID should change after insert and
refresh",
+ snapshotIdBefore, snapshotIdAfter);
+ }
+
+ @Test
+ public void testMetadataTamperproofing() throws IOException {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_tamperproof_table");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint, data string) STORED BY iceberg
%s",
+ identifier.name(), encryptionProps()));
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (2, 'b')", identifier.name()));
+
+ Table table = testTables.loadTable(identifier);
+ TableMetadata metadata = ((BaseTable) table).operations().current();
+
+ Path currentMetadataPath = new Path(metadata.metadataFileLocation());
+ Path previousMetadataPath = new
Path(metadata.previousFiles().get(0).file());
+
+ ChecksumFileSystem fs = (ChecksumFileSystem) FileSystem.newInstance(new
Configuration());
+
+ // Tamper: replace current metadata file with a previous version
+ Path checksumFile = fs.getChecksumFile(currentMetadataPath);
+ fs.delete(checksumFile, false);
+ fs.delete(currentMetadataPath, false);
+ fs.rename(previousMetadataPath, currentMetadataPath);
+
+ AssertHelpers.assertThrows("Should detect metadata tampering",
+ RuntimeException.class,
+ "might have been modified. Hash of metadata loaded from storage
differs from HMS-stored metadata hash",
+ () -> testTables.loadTable(identifier));
+ }
+
+ @Test
+ public void testKeyDelete() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_key_delete_table");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint) STORED BY iceberg %s",
+ identifier.name(), encryptionProps()));
+
+ AssertHelpers.assertThrows("Should not allow removing encryption key",
+ IllegalArgumentException.class,
+ "Cannot remove key ID from an encrypted table",
+ () -> shell.executeStatement(String.format(
+ "ALTER TABLE %s UNSET TBLPROPERTIES ('%s')",
+ identifier.name(), TableProperties.ENCRYPTION_TABLE_KEY)));
+ }
+
+ @Test
+ public void testKeyAlter() {
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_key_alter_table");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id bigint) STORED BY iceberg %s",
+ identifier.name(), encryptionProps()));
+
+ AssertHelpers.assertThrows("Should not allow modifying encryption key",
+ IllegalArgumentException.class,
+ "Cannot modify key ID of an encrypted table",
+ () -> shell.executeStatement(String.format(
+ "ALTER TABLE %s SET TBLPROPERTIES ('%s'='abcd')",
+ identifier.name(), TableProperties.ENCRYPTION_TABLE_KEY)));
+ }
+
+ @Test
+ public void testDirectDataFileRead() {
+ // Reads the raw data file and asserts a Parquet encrypted-footer
exception, so it is Parquet-specific.
+ Assume.assumeTrue(fileFormat == FileFormat.PARQUET);
+ TableIdentifier identifier = TableIdentifier.of("default",
"encrypted_direct_read_table");
+
+ shell.executeStatement(String.format(
+ "CREATE EXTERNAL TABLE %s (id int, data string) STORED BY iceberg %s",
+ identifier.name(), encryptionProps()));
+ shell.executeStatement(String.format(
+ "INSERT INTO %s VALUES (1, 'a')", identifier.name()));
+
+ // Load table and extract data files via the Iceberg Java API
+ Table table = testTables.loadTable(identifier);
+ List<DataFile> dataFiles = Lists.newArrayList();
+ table.newScan().planFiles().forEach(task -> dataFiles.add(task.file()));
+
+ Assert.assertFalse("Data files should not be empty", dataFiles.isEmpty());
+
+ Schema schema = new Schema(Types.NestedField.optional(1, "id",
Types.IntegerType.get()));
+
+ // Attempt to read the encrypted Parquet files directly without keys
+ for (DataFile dataFile : dataFiles) {
+ try {
+ Parquet.read(localInput(dataFile.path().toString()))
+ .project(schema)
+ .callInit()
+ .build()
+ .iterator()
+ .next();
+ Assert.fail("Should have thrown an exception when reading encrypted
file directly");
+ } catch (Exception e) {
+ Assert.assertTrue("Exception should be related to encrypted footer",
+ e instanceof ParquetCryptoRuntimeException ||
+ String.valueOf(e).contains("Trying to read file with encrypted
footer. No keys available"));
+ }
+ }
+ }
+}
diff --git
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/test/TestTables.java
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/test/TestTables.java
index 9d073aa81f1..23771129e86 100644
---
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/test/TestTables.java
+++
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/test/TestTables.java
@@ -46,6 +46,7 @@
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.data.Record;
+import org.apache.iceberg.encryption.UnitestKMS;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.hadoop.HadoopCatalog;
import org.apache.iceberg.hadoop.HadoopTables;
@@ -626,7 +627,8 @@ static class HiveTestTables extends TestTables {
HiveTestTables(Configuration conf, TemporaryFolder temp, String
catalogName) {
super(CatalogUtil.loadCatalog(HiveCatalog.class.getName(),
CatalogUtil.ICEBERG_CATALOG_TYPE_HIVE,
- ImmutableMap.of(), conf), temp, catalogName);
+ ImmutableMap.of(CatalogProperties.ENCRYPTION_KMS_IMPL,
UnitestKMS.class.getCanonicalName()), conf), temp,
+ catalogName);
}
@Override
diff --git
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/vector/TestHiveVectorizedReader.java
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/vector/TestHiveVectorizedReader.java
index c73cc0f332d..91a77404eed 100644
---
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/vector/TestHiveVectorizedReader.java
+++
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/mr/hive/vector/TestHiveVectorizedReader.java
@@ -40,7 +40,7 @@
import
org.apache.iceberg.mr.mapred.MapredIcebergInputFormat.CompatibilityTaskAttemptContextImpl;
import org.apache.iceberg.mr.mapreduce.IcebergInputFormat;
import org.apache.iceberg.types.Types;
-import org.apache.parquet.format.converter.ParquetMetadataConverter;
+import org.apache.parquet.ParquetReadOptions;
import org.apache.parquet.hadoop.ParquetFileReader;
import org.apache.parquet.io.InputFile;
import org.junit.Assert;
@@ -103,8 +103,8 @@ public void testRecordReaderShouldReuseFooter() throws
IOException, InterruptedE
r.nextKeyValue();
}
}
- mockedParquetFileReader.verify(() ->
ParquetFileReader.readFooter(any(InputFile.class),
- any(ParquetMetadataConverter.MetadataFilter.class)), times(1)
+ mockedParquetFileReader.verify(() ->
ParquetFileReader.open(any(InputFile.class),
+ any(ParquetReadOptions.class)), times(1)
);
}
}
diff --git
a/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/parquet/TestParquetFooterInputFromCache.java
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/parquet/TestParquetFooterInputFromCache.java
new file mode 100644
index 00000000000..ca79fd948ed
--- /dev/null
+++
b/iceberg/iceberg-handler/src/test/java/org/apache/iceberg/parquet/TestParquetFooterInputFromCache.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.parquet;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.apache.hadoop.hive.common.io.encoded.MemoryBuffer;
+import org.apache.hadoop.hive.common.io.encoded.MemoryBufferOrBuffers;
+import org.apache.parquet.hadoop.ParquetFileWriter;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class TestParquetFooterInputFromCache {
+
+ @Test
+ public void testUnencryptedMagicAtPrefixAndTail() throws IOException {
+ byte[] footerBytes = new byte[] {10, 20, 30, 40, 50};
+ MemoryBufferOrBuffers footerData = singleBuffer(footerBytes);
+
+ ParquetFooterInputFromCache input = new
ParquetFooterInputFromCache(footerData);
+
+ // Total length: prefix(4) + footer(5) + tail(4 + 4) = 17
+ Assert.assertEquals(17, input.getLength());
+
+ // Read prefix: should be PAR1
+ input.seek(0);
+ byte[] prefix = new byte[4];
+ input.readFully(prefix);
+ Assert.assertArrayEquals(ParquetFileWriter.MAGIC, prefix);
+
+ // Read tail magic: last 4 bytes should be PAR1
+ input.seek(input.getLength() - 4);
+ byte[] tailMagic = new byte[4];
+ input.readFully(tailMagic);
+ Assert.assertArrayEquals(ParquetFileWriter.MAGIC, tailMagic);
+
+ // Read footer length from tail: 4 bytes before the tail magic
+ input.seek(input.getLength() - 8);
+ byte[] lenBytes = new byte[4];
+ input.readFully(lenBytes);
+ int footerLength = (lenBytes[0] & 0xFF) | ((lenBytes[1] & 0xFF) << 8) |
+ ((lenBytes[2] & 0xFF) << 16) | ((lenBytes[3] & 0xFF) << 24);
+ Assert.assertEquals(5, footerLength);
+ }
+
+ @Test
+ public void testEncryptedMagicAtPrefixAndTail() throws IOException {
+ byte[] footerBytes = new byte[] {1, 2, 3, 4, 5, 6, 7};
+ MemoryBufferOrBuffers footerData = singleBuffer(footerBytes);
+
+ ParquetFooterInputFromCache input =
+ new ParquetFooterInputFromCache(footerData, ParquetFileWriter.EFMAGIC);
+
+ // Total length: prefix(4) + footer(7) + tail(4 + 4) = 19
+ Assert.assertEquals(19, input.getLength());
+
+ // Read prefix: should be PARE
+ input.seek(0);
+ byte[] prefix = new byte[4];
+ input.readFully(prefix);
+ Assert.assertArrayEquals(ParquetFileWriter.EFMAGIC, prefix);
+
+ // Read tail magic: last 4 bytes should be PARE
+ input.seek(input.getLength() - 4);
+ byte[] tailMagic = new byte[4];
+ input.readFully(tailMagic);
+ Assert.assertArrayEquals(ParquetFileWriter.EFMAGIC, tailMagic);
+
+ // Read footer length from tail
+ input.seek(input.getLength() - 8);
+ byte[] lenBytes = new byte[4];
+ input.readFully(lenBytes);
+ int footerLength = (lenBytes[0] & 0xFF) | ((lenBytes[1] & 0xFF) << 8) |
+ ((lenBytes[2] & 0xFF) << 16) | ((lenBytes[3] & 0xFF) << 24);
+ Assert.assertEquals(7, footerLength);
+ }
+
+ @Test
+ public void testFooterDataReadCorrectly() throws IOException {
+ byte[] footerBytes = new byte[] {0x11, 0x22, 0x33, 0x44, 0x55};
+ MemoryBufferOrBuffers footerData = singleBuffer(footerBytes);
+
+ ParquetFooterInputFromCache input =
+ new ParquetFooterInputFromCache(footerData, ParquetFileWriter.EFMAGIC);
+
+ // Footer data starts after prefix (4 bytes)
+ input.seek(4);
+ byte[] readBack = new byte[5];
+ input.readFully(readBack);
+ Assert.assertArrayEquals(footerBytes, readBack);
+ }
+
+ @Test
+ public void testSequentialReadAcrossAllRegions() throws IOException {
+ byte[] footerBytes = new byte[] {(byte) 0xAA, (byte) 0xBB};
+ MemoryBufferOrBuffers footerData = singleBuffer(footerBytes);
+
+ ParquetFooterInputFromCache input =
+ new ParquetFooterInputFromCache(footerData, ParquetFileWriter.EFMAGIC);
+
+ // Length: prefix(4) + footer(2) + tail(8) = 14
+ Assert.assertEquals(14, input.getLength());
+
+ // Read entire content sequentially from position 0
+ input.seek(0);
+ byte[] all = new byte[14];
+ input.readFully(all);
+
+ // Verify prefix: PARE
+ Assert.assertEquals('P', all[0]);
+ Assert.assertEquals('A', all[1]);
+ Assert.assertEquals('R', all[2]);
+ Assert.assertEquals('E', all[3]);
+
+ // Verify footer data
+ Assert.assertEquals((byte) 0xAA, all[4]);
+ Assert.assertEquals((byte) 0xBB, all[5]);
+
+ // Verify tail: footer length (2, little-endian) + PARE
+ Assert.assertEquals(2, all[6]);
+ Assert.assertEquals(0, all[7]);
+ Assert.assertEquals(0, all[8]);
+ Assert.assertEquals(0, all[9]);
+ Assert.assertEquals('P', all[10]);
+ Assert.assertEquals('A', all[11]);
+ Assert.assertEquals('R', all[12]);
+ Assert.assertEquals('E', all[13]);
+ }
+
+ @Test
+ public void testMultipleBuffers() throws IOException {
+ byte[] part1 = new byte[] {1, 2, 3};
+ byte[] part2 = new byte[] {4, 5};
+ MemoryBufferOrBuffers footerData = multipleBuffers(part1, part2);
+
+ ParquetFooterInputFromCache input =
+ new ParquetFooterInputFromCache(footerData, ParquetFileWriter.MAGIC);
+
+ // Length: prefix(4) + footer(5) + tail(8) = 17
+ Assert.assertEquals(17, input.getLength());
+
+ // Read footer region which spans two buffers
+ input.seek(4);
+ byte[] footer = new byte[5];
+ input.readFully(footer);
+ Assert.assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, footer);
+ }
+
+ private static MemoryBufferOrBuffers singleBuffer(byte[] data) {
+ MemoryBuffer buf = new TestMemoryBuffer(data);
+ return new MemoryBufferOrBuffers() {
+ @Override
+ public MemoryBuffer getSingleBuffer() {
+ return buf;
+ }
+
+ @Override
+ public MemoryBuffer[] getMultipleBuffers() {
+ return null;
+ }
+ };
+ }
+
+ private static MemoryBufferOrBuffers multipleBuffers(byte[]... parts) {
+ MemoryBuffer[] bufs = new MemoryBuffer[parts.length];
+ for (int i = 0; i < parts.length; i++) {
+ bufs[i] = new TestMemoryBuffer(parts[i]);
+ }
+ return new MemoryBufferOrBuffers() {
+ @Override
+ public MemoryBuffer getSingleBuffer() {
+ return null;
+ }
+
+ @Override
+ public MemoryBuffer[] getMultipleBuffers() {
+ return bufs;
+ }
+ };
+ }
+
+ private static class TestMemoryBuffer implements MemoryBuffer {
+ private final ByteBuffer bb;
+
+ TestMemoryBuffer(byte[] data) {
+ bb = ByteBuffer.wrap(data);
+ }
+
+ @Override
+ public ByteBuffer getByteBufferRaw() {
+ return bb;
+ }
+
+ @Override
+ public ByteBuffer getByteBufferDup() {
+ return bb.duplicate();
+ }
+ }
+}
diff --git
a/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/DVUtil.java
b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/DVUtil.java
new file mode 100644
index 00000000000..5f67d57e540
--- /dev/null
+++ b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/DVUtil.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import org.apache.iceberg.deletes.DVFileWriter;
+import org.apache.iceberg.deletes.Deletes;
+import org.apache.iceberg.deletes.PositionDeleteIndex;
+import org.apache.iceberg.encryption.EncryptedFiles;
+import org.apache.iceberg.encryption.EncryptedOutputFile;
+import org.apache.iceberg.encryption.EncryptingFileIO;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.IOUtil;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.relocated.com.google.common.collect.Multimap;
+import org.apache.iceberg.relocated.com.google.common.collect.Multimaps;
+import org.apache.iceberg.types.Comparators;
+import org.apache.iceberg.util.ContentFileUtil;
+import org.apache.iceberg.util.Pair;
+import org.apache.iceberg.util.Tasks;
+
+class DVUtil {
+ private DVUtil() {
+ }
+
+ static PositionDeleteIndex readDV(DeleteFile deleteFile, FileIO fileIO) {
+ Preconditions.checkArgument(
+ ContentFileUtil.isDV(deleteFile),
+ "Cannot read, not a deletion vector: %s",
+ deleteFile.location());
+ InputFile inputFile = fileIO.newInputFile(deleteFile);
+ long offset = deleteFile.contentOffset();
+ int length = deleteFile.contentSizeInBytes().intValue();
+ byte[] bytes = new byte[length];
+ try {
+ IOUtil.readFully(inputFile, offset, bytes, 0, length);
+ return PositionDeleteIndex.deserialize(bytes, deleteFile);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Merges duplicate DVs for the same data file and writes the merged DV
Puffin files. If there is
+ * exactly 1 DV for a given data file then it is return as is
+ *
+ * @param dvsByReferencedFile map of data file location to DVs
+ * @param mergedOutputLocation output location of the merged DVs
+ * @param fileIO fileIO to use when reading and writing
+ * @param specs partition specs
+ * @param pool executor service for reading DVs
+ * @return a list containing both any newly merged DVs and any DVs that are
already valid
+ */
+ static List<DeleteFile> mergeAndWriteDVsIfRequired(
+ Map<String, List<DeleteFile>> dvsByReferencedFile,
+ String mergedOutputLocation,
+ FileIO fileIO,
+ Map<Integer, PartitionSpec> specs,
+ ExecutorService pool) {
+ List<DeleteFile> finalDVs = Lists.newArrayList();
+ Multimap<String, DeleteFile> duplicates =
+ Multimaps.newListMultimap(Maps.newHashMap(), Lists::newArrayList);
+ Map<String, Pair<PartitionSpec, StructLike>> partitions =
Maps.newHashMap();
+
+ for (Map.Entry<String, List<DeleteFile>> entry :
dvsByReferencedFile.entrySet()) {
+ if (entry.getValue().size() > 1) {
+ duplicates.putAll(entry.getKey(), entry.getValue());
+ DeleteFile first = entry.getValue().get(0);
+ partitions.put(entry.getKey(), Pair.of(specs.get(first.specId()),
first.partition()));
+ } else {
+ finalDVs.addAll(entry.getValue());
+ }
+ }
+
+ if (duplicates.isEmpty()) {
+ return finalDVs;
+ }
+
+ validateCanMerge(duplicates, partitions);
+
+ Map<String, PositionDeleteIndex> deletes =
+ readAndMergeDVs(duplicates.values().toArray(DeleteFile[]::new),
fileIO, pool);
+
+ finalDVs.addAll(writeDVs(deletes, fileIO, mergedOutputLocation,
partitions));
+ return finalDVs;
+ }
+
+ private static void validateCanMerge(
+ Multimap<String, DeleteFile> duplicates,
+ Map<String, Pair<PartitionSpec, StructLike>> partitions) {
+ Map<Integer, Comparator<StructLike>> comparatorsBySpecId =
Maps.newHashMap();
+ for (Map.Entry<String, Collection<DeleteFile>> entry :
duplicates.asMap().entrySet()) {
+ String referencedFile = entry.getKey();
+
+ // validate that each file matches the expected partition
+ Pair<PartitionSpec, StructLike> partition =
partitions.get(referencedFile);
+ Long sequenceNumber = Iterables.getFirst(entry.getValue(),
null).dataSequenceNumber();
+ PartitionSpec spec = partition.first();
+ StructLike tuple = partition.second();
+ Comparator<StructLike> comparator =
+ comparatorsBySpecId.computeIfAbsent(
+ spec.specId(), id -> Comparators.forType(spec.partitionType()));
+
+ for (DeleteFile dv : entry.getValue()) {
+ Preconditions.checkArgument(
+ Objects.equals(sequenceNumber, dv.dataSequenceNumber()),
+ "Cannot merge DVs, mismatched sequence numbers (%s, %s) for %s",
+ sequenceNumber,
+ dv.dataSequenceNumber(),
+ referencedFile);
+
+ Preconditions.checkArgument(
+ spec.specId() == dv.specId(),
+ "Cannot merge DVs, mismatched partition specs (%s, %s) for %s",
+ spec.specId(),
+ dv.specId(),
+ referencedFile);
+
+ Preconditions.checkArgument(
+ comparator.compare(tuple, dv.partition()) == 0,
+ "Cannot merge DVs, mismatched partition tuples (%s, %s) for %s",
+ tuple,
+ dv.partition(),
+ referencedFile);
+ }
+ }
+ }
+
+ /**
+ * Reads all DVs, and merge the position indices per referenced data file
+ *
+ * @param duplicateDVs list of dvs to read and merge
+ * @param io the FileIO to use for reading DV files
+ * @param pool executor service for reading DVs
+ * @return map of referenced data file location to the merged position
delete index
+ */
+ private static Map<String, PositionDeleteIndex> readAndMergeDVs(
+ DeleteFile[] duplicateDVs, FileIO io, ExecutorService pool) {
+ // Read all duplicate DVs in parallel
+ PositionDeleteIndex[] duplicatedDVPositions = new
PositionDeleteIndex[duplicateDVs.length];
+ Tasks.range(duplicatedDVPositions.length)
+ .executeWith(pool)
+ .stopOnFailure()
+ .throwFailureWhenFinished()
+ .run(i -> duplicatedDVPositions[i] = readDV(duplicateDVs[i], io));
+
+ Map<String, PositionDeleteIndex> mergedDVs = Maps.newHashMap();
+ for (int i = 0; i < duplicatedDVPositions.length; i++) {
+ DeleteFile dv = duplicateDVs[i];
+ PositionDeleteIndex previousDV =
mergedDVs.get(duplicateDVs[i].referencedDataFile());
+ if (previousDV != null) {
+ previousDV.merge(duplicatedDVPositions[i]);
+ } else {
+ mergedDVs.put(dv.referencedDataFile(), duplicatedDVPositions[i]);
+ }
+ }
+
+ return mergedDVs;
+ }
+
+ // Produces a single Puffin file containing the merged DVs
+ private static List<DeleteFile> writeDVs(
+ Map<String, PositionDeleteIndex> mergedIndexByFile,
+ FileIO fileIO,
+ String dvOutputLocation,
+ Map<String, Pair<PartitionSpec, StructLike>> partitions) {
+ EncryptedOutputFile dvOutputFile =
+ fileIO instanceof EncryptingFileIO encryptingFileIO ?
+ encryptingFileIO.newEncryptingOutputFile(dvOutputLocation) :
+
EncryptedFiles.plainAsEncryptedOutput(fileIO.newOutputFile(dvOutputLocation));
+
+ try (DVFileWriter dvFileWriter = Deletes.writeDVs(dvOutputFile, path ->
null)) {
+ for (Map.Entry<String, PositionDeleteIndex> entry :
mergedIndexByFile.entrySet()) {
+ String referencedLocation = entry.getKey();
+ PositionDeleteIndex mergedPositions = entry.getValue();
+ Pair<PartitionSpec, StructLike> partition =
partitions.get(referencedLocation);
+ dvFileWriter.delete(
+ referencedLocation, mergedPositions, partition.first(),
partition.second());
+ }
+ dvFileWriter.close();
+ return dvFileWriter.result().deleteFiles();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git
a/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java
b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java
new file mode 100644
index 00000000000..ab8c85b50eb
--- /dev/null
+++
b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.deletes;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.FileMetadata;
+import org.apache.iceberg.IcebergBuild;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.encryption.EncryptedFiles;
+import org.apache.iceberg.encryption.EncryptedOutputFile;
+import org.apache.iceberg.encryption.EncryptionKeyMetadata;
+import org.apache.iceberg.encryption.NativeEncryptionKeyMetadata;
+import org.apache.iceberg.io.DeleteWriteResult;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.OutputFileFactory;
+import org.apache.iceberg.puffin.Blob;
+import org.apache.iceberg.puffin.BlobMetadata;
+import org.apache.iceberg.puffin.Puffin;
+import org.apache.iceberg.puffin.PuffinWriter;
+import org.apache.iceberg.puffin.StandardBlobTypes;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.util.CharSequenceSet;
+import org.apache.iceberg.util.ContentFileUtil;
+import org.apache.iceberg.util.StructLikeUtil;
+
+public class BaseDVFileWriter implements DVFileWriter {
+
+ private static final String REFERENCED_DATA_FILE_KEY =
"referenced-data-file";
+ private static final String CARDINALITY_KEY = "cardinality";
+
+ private final Supplier<EncryptedOutputFile> dvOutputFile;
+ private final Function<String, PositionDeleteIndex> loadPreviousDeletes;
+ private final Map<String, Deletes> deletesByPath = Maps.newHashMap();
+ private final Map<String, BlobMetadata> blobsByPath = Maps.newHashMap();
+ private DeleteWriteResult result = null;
+
+ public BaseDVFileWriter(
+ OutputFileFactory fileFactory, Function<String, PositionDeleteIndex>
loadPreviousDeletes) {
+ this(loadPreviousDeletes, fileFactory::newOutputFile);
+ }
+
+ public BaseDVFileWriter(
+ Supplier<OutputFile> dvOutputFile,
+ Function<String, PositionDeleteIndex> loadPreviousDeletes) {
+ this(loadPreviousDeletes, () ->
EncryptedFiles.plainAsEncryptedOutput(dvOutputFile.get()));
+ }
+
+ BaseDVFileWriter(
+ Function<String, PositionDeleteIndex> loadPreviousDeletes,
+ Supplier<EncryptedOutputFile> dvOutputFile) {
+ this.dvOutputFile = dvOutputFile;
+ this.loadPreviousDeletes = loadPreviousDeletes;
+ }
+
+ @Override
+ public void delete(String path, long pos, PartitionSpec spec, StructLike
partition) {
+ Deletes deletes =
+ deletesByPath.computeIfAbsent(path, key -> new Deletes(path, spec,
partition));
+ PositionDeleteIndex positions = deletes.positions();
+ positions.delete(pos);
+ }
+
+ @Override
+ public void delete(
+ String path,
+ PositionDeleteIndex positionDeleteIndex,
+ PartitionSpec spec,
+ StructLike partition) {
+ Deletes deletes =
+ deletesByPath.computeIfAbsent(path, key -> new Deletes(path, spec,
partition));
+ deletes.positions().merge(positionDeleteIndex);
+ }
+
+ @Override
+ public DeleteWriteResult result() {
+ Preconditions.checkState(result != null, "Cannot get result from unclosed
writer");
+ return result;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (result == null) {
+ List<DeleteFile> dvs = Lists.newArrayList();
+ CharSequenceSet referencedDataFiles = CharSequenceSet.empty();
+ List<DeleteFile> rewrittenDeleteFiles = Lists.newArrayList();
+
+ // Only create PuffinWriter if there are deletes to write
+ if (deletesByPath.isEmpty()) {
+ this.result = new DeleteWriteResult(dvs, referencedDataFiles,
rewrittenDeleteFiles);
+ return;
+ }
+
+ EncryptedOutputFile outputFile = dvOutputFile.get();
+ EncryptionKeyMetadata keyMetadata = outputFile.keyMetadata();
+ PuffinWriter writer =
+ Puffin.write(outputFile.encryptingOutputFile())
+ .createdBy(IcebergBuild.fullVersion())
+ .build();
+
+ try (PuffinWriter closeableWriter = writer) {
+ for (Deletes deletes : deletesByPath.values()) {
+ String path = deletes.path();
+ PositionDeleteIndex positions = deletes.positions();
+ PositionDeleteIndex previousPositions =
loadPreviousDeletes.apply(path);
+ if (previousPositions != null) {
+ positions.merge(previousPositions);
+ for (DeleteFile previousDeleteFile :
previousPositions.deleteFiles()) {
+ // only DVs and file-scoped deletes can be discarded from the
table state
+ if (ContentFileUtil.isFileScoped(previousDeleteFile)) {
+ rewrittenDeleteFiles.add(previousDeleteFile);
+ }
+ }
+ }
+ write(closeableWriter, deletes);
+ referencedDataFiles.add(path);
+ }
+ }
+
+ // DVs share the Puffin path and file size but have different offsets
+ String puffinPath = writer.location();
+ long puffinFileSize = writer.fileSize();
+
+ for (String path : deletesByPath.keySet()) {
+ DeleteFile dv = createDV(puffinPath, puffinFileSize, path,
keyMetadata);
+ dvs.add(dv);
+ }
+
+ this.result = new DeleteWriteResult(dvs, referencedDataFiles,
rewrittenDeleteFiles);
+ }
+ }
+
+ private DeleteFile createDV(
+ String path, long size, String referencedDataFile, EncryptionKeyMetadata
keyMetadata) {
+ Deletes deletes = deletesByPath.get(referencedDataFile);
+ BlobMetadata blobMetadata = blobsByPath.get(referencedDataFile);
+ return FileMetadata.deleteFileBuilder(deletes.spec())
+ .ofPositionDeletes()
+ .withFormat(FileFormat.PUFFIN)
+ .withPath(path)
+ .withPartition(deletes.partition())
+ .withFileSizeInBytes(size)
+ .withEncryptionKeyMetadata(encryptionKeyMetadata(size, keyMetadata))
+ .withReferencedDataFile(referencedDataFile)
+ .withContentOffset(blobMetadata.offset())
+ .withContentSizeInBytes(blobMetadata.length())
+ .withRecordCount(deletes.positions().cardinality())
+ .build();
+ }
+
+ private void write(PuffinWriter writer, Deletes deletes) {
+ String path = deletes.path();
+ PositionDeleteIndex positions = deletes.positions();
+ BlobMetadata blobMetadata = writer.write(toBlob(positions, path));
+ blobsByPath.put(path, blobMetadata);
+ }
+
+ private ByteBuffer encryptionKeyMetadata(
+ long fileSizeInBytes, EncryptionKeyMetadata keyMetadata) {
+ if (keyMetadata instanceof NativeEncryptionKeyMetadata nativeKeyMetadata) {
+ return nativeKeyMetadata.copyWithLength(fileSizeInBytes).buffer();
+ }
+
+ return keyMetadata.buffer();
+ }
+
+ private Blob toBlob(PositionDeleteIndex positions, String path) {
+ return new Blob(
+ StandardBlobTypes.DV_V1,
+ ImmutableList.of(MetadataColumns.ROW_POSITION.fieldId()),
+ -1 /* snapshot ID is inherited */,
+ -1 /* sequence number is inherited */,
+ positions.serialize(),
+ null /* uncompressed */,
+ ImmutableMap.of(
+ REFERENCED_DATA_FILE_KEY,
+ path,
+ CARDINALITY_KEY,
+ String.valueOf(positions.cardinality())));
+ }
+
+ private static class Deletes {
+ private final String path;
+ private final PositionDeleteIndex positions;
+ private final PartitionSpec spec;
+ private final StructLike partition;
+
+ private Deletes(String path, PartitionSpec spec, StructLike partition) {
+ this.path = path;
+ this.positions = new BitmapPositionDeleteIndex();
+ this.spec = spec;
+ this.partition = StructLikeUtil.copy(partition);
+ }
+
+ public String path() {
+ return path;
+ }
+
+ public PositionDeleteIndex positions() {
+ return positions;
+ }
+
+ public PartitionSpec spec() {
+ return spec;
+ }
+
+ public StructLike partition() {
+ return partition;
+ }
+ }
+}
diff --git
a/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/Deletes.java
b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/Deletes.java
new file mode 100644
index 00000000000..127c8046f9e
--- /dev/null
+++
b/iceberg/patched-iceberg-core/src/main/java/org/apache/iceberg/deletes/Deletes.java
@@ -0,0 +1,242 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.deletes;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.apache.iceberg.Accessor;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.encryption.EncryptedOutputFile;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Comparators;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.CharSequenceMap;
+import org.apache.iceberg.util.Filter;
+import org.apache.iceberg.util.SortedMerge;
+import org.apache.iceberg.util.StructLikeSet;
+
+public class Deletes {
+
+ private static final Schema POSITION_DELETE_SCHEMA =
+ new Schema(MetadataColumns.DELETE_FILE_PATH,
MetadataColumns.DELETE_FILE_POS);
+
+ private static final Accessor<StructLike> FILENAME_ACCESSOR =
+
POSITION_DELETE_SCHEMA.accessorForField(MetadataColumns.DELETE_FILE_PATH.fieldId());
+ private static final Accessor<StructLike> POSITION_ACCESSOR =
+
POSITION_DELETE_SCHEMA.accessorForField(MetadataColumns.DELETE_FILE_POS.fieldId());
+
+ private Deletes() {
+ }
+
+ public static DVFileWriter writeDVs(
+ EncryptedOutputFile outputFile, Function<String, PositionDeleteIndex>
loadPreviousDeletes) {
+ return new BaseDVFileWriter(loadPreviousDeletes, () -> outputFile);
+ }
+
+ public static <T> CloseableIterable<T> filter(
+ CloseableIterable<T> rows, Function<T, StructLike> rowToDeleteKey,
StructLikeSet deleteSet) {
+ if (deleteSet.isEmpty()) {
+ return rows;
+ }
+
+ EqualitySetDeleteFilter<T> equalityFilter =
+ new EqualitySetDeleteFilter<>(rowToDeleteKey, deleteSet);
+ return equalityFilter.filter(rows);
+ }
+
+ /**
+ * Returns the same rows that are input, while marking the deleted ones.
+ *
+ * @param rows the rows to process
+ * @param isDeleted a predicate that determines if a row is deleted
+ * @param deleteMarker a function that marks a row as deleted
+ * @return the processed rows
+ */
+ public static <T> CloseableIterable<T> markDeleted(
+ CloseableIterable<T> rows, Predicate<T> isDeleted, Consumer<T>
deleteMarker) {
+ return CloseableIterable.transform(
+ rows,
+ row -> {
+ if (isDeleted.test(row)) {
+ deleteMarker.accept(row);
+ }
+
+ return row;
+ });
+ }
+
+ /**
+ * Returns the remaining rows (the ones that are not deleted), while
counting the deleted ones.
+ *
+ * @param rows the rows to process
+ * @param isDeleted a predicate that determines if a row is deleted
+ * @param counter a counter that counts deleted rows
+ * @return the processed rows
+ */
+ public static <T> CloseableIterable<T> filterDeleted(
+ CloseableIterable<T> rows, Predicate<T> isDeleted, DeleteCounter
counter) {
+ Filter<T> remainingRowsFilter =
+ new Filter<T>() {
+ @Override
+ protected boolean shouldKeep(T item) {
+ boolean deleted = isDeleted.test(item);
+ if (deleted) {
+ counter.increment();
+ }
+
+ return !deleted;
+ }
+ };
+
+ return remainingRowsFilter.filter(rows);
+ }
+
+ public static StructLikeSet toEqualitySet(
+ CloseableIterable<StructLike> eqDeletes, Types.StructType eqType) {
+ try (CloseableIterable<StructLike> deletes = eqDeletes) {
+ StructLikeSet deleteSet = StructLikeSet.create(eqType);
+ Iterables.addAll(deleteSet, deletes);
+ return deleteSet;
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to close equality delete source",
e);
+ }
+ }
+
+ public static <T extends StructLike> CharSequenceMap<PositionDeleteIndex>
toPositionIndexes(
+ CloseableIterable<T> posDeletes) {
+ return toPositionIndexes(posDeletes, null /* unknown delete file */);
+ }
+
+ /**
+ * Builds a map of position delete indexes by path.
+ *
+ * <p>This method builds a position delete index for each referenced data
file and does not filter
+ * deletes. This can be useful when the entire delete file content is needed
(e.g. caching).
+ *
+ * @param posDeletes position deletes
+ * @param file the source delete file for the deletes
+ * @return the map of position delete indexes by path
+ */
+ public static <T extends StructLike> CharSequenceMap<PositionDeleteIndex>
toPositionIndexes(
+ CloseableIterable<T> posDeletes, DeleteFile file) {
+ CharSequenceMap<PositionDeleteIndex> indexes = CharSequenceMap.create();
+
+ try (CloseableIterable<T> deletes = posDeletes) {
+ for (T delete : deletes) {
+ CharSequence filePath = (CharSequence) FILENAME_ACCESSOR.get(delete);
+ long position = (long) POSITION_ACCESSOR.get(delete);
+ PositionDeleteIndex index =
+ indexes.computeIfAbsent(filePath, key -> new
BitmapPositionDeleteIndex(file));
+ index.delete(position);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to close position delete source",
e);
+ }
+
+ return indexes;
+ }
+
+ public static <T extends StructLike> PositionDeleteIndex toPositionIndex(
+ CharSequence dataLocation, CloseableIterable<T> posDeletes, DeleteFile
file) {
+ CloseableIterable<Long> positions = extractPositions(dataLocation,
posDeletes);
+ List<DeleteFile> files = ImmutableList.of(file);
+ return toPositionIndex(positions, files);
+ }
+
+ private static <T extends StructLike> CloseableIterable<Long>
extractPositions(
+ CharSequence dataLocation, CloseableIterable<T> rows) {
+ DataFileFilter<T> filter = new DataFileFilter<>(dataLocation);
+ CloseableIterable<T> filteredRows = filter.filter(rows);
+ return CloseableIterable.transform(filteredRows, row -> (Long)
POSITION_ACCESSOR.get(row));
+ }
+
+ public static PositionDeleteIndex toPositionIndex(CloseableIterable<Long>
posDeletes) {
+ return toPositionIndex(posDeletes, ImmutableList.of());
+ }
+
+ private static PositionDeleteIndex toPositionIndex(
+ CloseableIterable<Long> posDeletes, List<DeleteFile> files) {
+ try (CloseableIterable<Long> deletes = posDeletes) {
+ PositionDeleteIndex positionDeleteIndex = new
BitmapPositionDeleteIndex(files);
+ deletes.forEach(positionDeleteIndex::delete);
+ return positionDeleteIndex;
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to close position delete source",
e);
+ }
+ }
+
+ public static CloseableIterable<Long> deletePositions(
+ CharSequence dataLocation, CloseableIterable<StructLike> deleteFile) {
+ return deletePositions(dataLocation, ImmutableList.of(deleteFile));
+ }
+
+ public static <T extends StructLike> CloseableIterable<Long> deletePositions(
+ CharSequence dataLocation, List<CloseableIterable<T>> deleteFiles) {
+ DataFileFilter<T> locationFilter = new DataFileFilter<>(dataLocation);
+ List<CloseableIterable<Long>> positions =
+ Lists.transform(
+ deleteFiles,
+ deletes ->
+ CloseableIterable.transform(
+ locationFilter.filter(deletes), row -> (Long)
POSITION_ACCESSOR.get(row)));
+
+ return new SortedMerge<>(Long::compare, positions);
+ }
+
+ private static class EqualitySetDeleteFilter<T> extends Filter<T> {
+ private final StructLikeSet deletes;
+ private final Function<T, StructLike> extractEqStruct;
+
+ protected EqualitySetDeleteFilter(Function<T, StructLike> extractEq,
StructLikeSet deletes) {
+ this.extractEqStruct = extractEq;
+ this.deletes = deletes;
+ }
+
+ @Override
+ protected boolean shouldKeep(T row) {
+ return !deletes.contains(extractEqStruct.apply(row));
+ }
+ }
+
+ private static class DataFileFilter<T extends StructLike> extends Filter<T> {
+ private final CharSequence dataLocation;
+
+ DataFileFilter(CharSequence dataLocation) {
+ this.dataLocation = dataLocation;
+ }
+
+ @Override
+ protected boolean shouldKeep(T posDelete) {
+ return Comparators.filePath()
+ .compare(dataLocation, (CharSequence)
FILENAME_ACCESSOR.get(posDelete)) ==
+ 0;
+ }
+ }
+}