This is an automated email from the ASF dual-hosted git repository.
abstractdog 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 1bcf6d4b857 HIVE-28822: Concurrent INSERTs can silently lose rows or
fail with FileAlreadyExistsException on S3 (non-ACID) (#6642)
1bcf6d4b857 is described below
commit 1bcf6d4b8570d84ce6a252b39ffae445d2771991
Author: Bodor Laszlo <[email protected]>
AuthorDate: Wed Aug 19 10:48:50 2026 +0200
HIVE-28822: Concurrent INSERTs can silently lose rows or fail with
FileAlreadyExistsException on S3 (non-ACID) (#6642)
---
.../org/apache/hadoop/hive/common/FileUtils.java | 29 ++
.../java/org/apache/hadoop/hive/ql/QueryPlan.java | 33 ++
.../hadoop/hive/ql/exec/ParsedOutputFileName.java | 16 +-
.../org/apache/hadoop/hive/ql/io/AcidUtils.java | 13 +-
.../org/apache/hadoop/hive/ql/metadata/Hive.java | 114 ++++--
.../hive/ql/exec/ParsedOutputFileNameTest.java | 42 ++
.../hadoop/hive/ql/metadata/TestHiveCopyFiles.java | 86 +++-
.../hive/ql/metadata/TestHiveCopyFilesFakeS3.java | 299 ++++++++++++++
.../ql/metadata/TestInsertCopySuffixOnFakeS3.java | 442 +++++++++++++++++++++
.../hadoop/hive/ql/util/FakeS3FileSystem.java | 101 +++++
.../clientpositive/acid_convert_16hex_copy_tag.q | 53 +++
.../llap/acid_convert_16hex_copy_tag.q.out | 124 ++++++
.../metastore/TransactionalValidationListener.java | 4 +-
13 files changed, 1309 insertions(+), 47 deletions(-)
diff --git a/common/src/java/org/apache/hadoop/hive/common/FileUtils.java
b/common/src/java/org/apache/hadoop/hive/common/FileUtils.java
index faa13a61b1b..8a802fc77d3 100644
--- a/common/src/java/org/apache/hadoop/hive/common/FileUtils.java
+++ b/common/src/java/org/apache/hadoop/hive/common/FileUtils.java
@@ -85,6 +85,35 @@ public final class FileUtils {
private static final Random random = new Random();
public static final int IO_ERROR_SLEEP_TIME = 100;
+ /**
+ * Schemes whose single-file {@link FileSystem#rename(Path, Path)} is not
atomic-if-absent and
+ * can silently overwrite an existing destination when two concurrent
writers race between an
+ * {@code exists()} probe and the rename call (object stores where rename is
client-side
+ * copy+delete). Callers use this to decide whether to switch to a
uniqueness-tag copy suffix
+ * when moving output files. The list is in code because the set of unsafe
filesystems is a
+ * property of the filesystem implementation, not something an operator
should override.
+ * <p>
+ * Note on Azure: {@code abfs}/{@code abfss} only guarantee atomic rename
when the ADLS Gen2
+ * account has hierarchical namespace enabled; without HNS they degrade to
copy+delete like
+ * {@code wasb}. Since callers cannot cheaply tell the two apart at rename
time, the Azure
+ * schemes are included unconditionally — a false positive costs only a
slightly longer
+ * filename, whereas a false negative would be silent data loss.
+ */
+ public static final Set<String> NON_ATOMIC_RENAME_SCHEMES = new HashSet<>(
+ Arrays.asList("s3a", "s3n", "s3", "gs", "abfs", "abfss", "wasb",
"wasbs"));
+
+ /**
+ * @return {@code true} when the filesystem's URI scheme is one of the known
non-atomic-rename
+ * schemes ({@link #NON_ATOMIC_RENAME_SCHEMES}); {@code false}
otherwise (including a
+ * {@code null} fs or missing scheme).
+ */
+ public static boolean isNonAtomicRenameFs(FileSystem fs) {
+ if (fs == null || fs.getUri() == null || fs.getUri().getScheme() == null) {
+ return false;
+ }
+ return
NON_ATOMIC_RENAME_SCHEMES.contains(fs.getUri().getScheme().toLowerCase());
+ }
+
public static final PathFilter HIDDEN_FILES_PATH_FILTER = new PathFilter() {
@Override
public boolean accept(Path p) {
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java
b/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java
index 18e38fc0da1..36b01834b75 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java
@@ -37,7 +37,9 @@
import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets;
+import com.google.common.base.Strings;
import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hive.conf.HiveConf;
import org.apache.hadoop.hive.metastore.api.Schema;
import org.apache.hadoop.hive.ql.ddl.DDLDesc.DDLDescWithWriteId;
import org.apache.hadoop.hive.ql.exec.ConditionalTask;
@@ -312,6 +314,37 @@ public static String makeQueryId() {
+ UUID.randomUUID().toString();
}
+ /**
+ * Compute a compact per-query uniqueness tag used by the non-ACID rename
branch of
+ * {@link Hive#mvFile} to make each concurrent writer's destination key
unique on filesystems
+ * whose {@code rename} is not atomic-if-absent. The tag becomes the copy
suffix
+ * ({@code basename_copy_<tag>}) in place of the numeric {@code _copy_N}
counter.
+ * <p>
+ * Reads {@code hive.query.id} from the passed {@link HiveConf} and
delegates to
+ * {@link QueryPlan#extractUniquenessTag(String)} for the actual UUID → hex
derivation.
+ * The shape matches {@link ParsedOutputFileName}'s copy-index group so
downstream filename
+ * parsing (taskId, attemptId, copyIndex) keeps working.
+ */
+ public static String extractUniquenessTag(HiveConf conf) {
+ String qid = HiveConf.getVar(conf, HiveConf.ConfVars.HIVE_QUERY_ID);
+ if (Strings.isNullOrEmpty(qid)) {
+ throw new IllegalStateException("hive.query.id is required to derive a
unique destination name");
+ }
+ return QueryPlan.extractUniquenessTag(qid);
+ }
+
+ /**
+ * Extract a compact per-query uniqueness tag (16 lowercase hex chars) from
a query id emitted
+ * by {@link #makeQueryId()}. The tag is the {@code
getMostSignificantBits()} half of the UUID
+ * at the tail of the id, rendered as 16-char lowercase hex. Used by callers
that need a stable
+ * per-query filename component — e.g. {@code Hive.mvFile}'s destination
naming on filesystems
+ * whose {@code rename} is not atomic-if-absent.
+ */
+ public static String extractUniquenessTag(String queryId) {
+ UUID uuid = UUID.fromString(queryId.substring(queryId.lastIndexOf('_') +
1));
+ return String.format("%016x", uuid.getMostSignificantBits());
+ }
+
/**
* generate the operator graph and operator list for the given task based on
* the operators corresponding to that task.
diff --git
a/ql/src/java/org/apache/hadoop/hive/ql/exec/ParsedOutputFileName.java
b/ql/src/java/org/apache/hadoop/hive/ql/exec/ParsedOutputFileName.java
index 64d388b9262..f9c60a2dd2e 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/exec/ParsedOutputFileName.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/exec/ParsedOutputFileName.java
@@ -32,8 +32,11 @@
* 00001_02
* 00001_02.gz
* 00001_02.zlib.gz
- * 00001_02_copy_1
+ * 00001_02_copy_1 (numeric copy suffix, HDFS-style)
* 00001_02_copy_1.gz
+ * 00001_02_copy_abcd1234deadbeef (per-query uniqueness tag as
copy suffix,
+ * used on non-atomic-rename
filesystems)
+ * 00001_02_copy_abcd1234deadbeef.gz
* <p>
* All the components are here:
* tmp_(taskPrefix)00001_02_copy_1.zlib.gz
@@ -42,9 +45,9 @@ public class ParsedOutputFileName {
private static final Pattern COPY_FILE_NAME_TO_TASK_ID_REGEX =
Pattern.compile(
"^(.*?)?" + // any prefix
"(\\(.*\\))?" + // taskId prefix
- "([0-9]+)" + // taskId
- "(?:_([0-9]{1,6}))?" + // _<attemptId> (limited to 6 digits)
- "(?:_copy_([0-9]{1,6}))?" + // copy file index
+ "(\\d+)" + // taskId
+ "(?:_(\\d{1,6}))?" + // _<attemptId> (limited to 6 digits)
+ "(?:_copy_(\\d{1,6}|[0-9a-fA-F]{16}))?" + // copy suffix: numeric
counter, or 16-hex uniqueness tag
"(\\..*)?$"); // any suffix/file extension
public static ParsedOutputFileName parse(String fileName) {
@@ -109,6 +112,11 @@ public boolean isCopyFile() {
return copyIndex != null;
}
+ /**
+ * @return the copy suffix: either a numeric counter (HDFS-style) or an
8-hex per-query
+ * uniqueness tag (used on non-atomic-rename filesystems), or {@code
null} when the
+ * filename has no copy suffix.
+ */
public String getCopyIndex() {
return copyIndex;
}
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java
b/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java
index 1acd1f71ae2..decf7f36d2b 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/io/AcidUtils.java
@@ -241,9 +241,13 @@ private AcidUtils() {
Pattern.compile("[0-9]+_[0-9]+");
/**
* @see org.apache.hadoop.hive.ql.exec.Utilities#COPY_KEYWORD
+ *
+ * The copy suffix is either a numeric counter (HDFS/local: _copy_N) or a
+ * 16-hex per-query uniqueness tag (non-atomic-rename FS such as S3A:
+ * _copy_<queryTag>). See ParsedOutputFileName#REGEX.
*/
public static final Pattern ORIGINAL_PATTERN_COPY =
- Pattern.compile("[0-9]+_[0-9]+" + COPY_KEYWORD + "[0-9]+");
+ Pattern.compile("[0-9]+_[0-9]+" + COPY_KEYWORD +
"(?:[0-9]{1,6}|[0-9a-fA-F]{16})");
public static final PathFilter acidHiddenFileFilter = new PathFilter() {
@Override
@@ -451,8 +455,11 @@ public static BucketMetaData parse(String bucketFileName) {
return new BucketMetaData(bucketId, 0);
}
else if(ORIGINAL_PATTERN_COPY.matcher(bucketFileName).matches()) {
- int copyNumber = Integer.parseInt(
- bucketFileName.substring(bucketFileName.lastIndexOf('_') + 1));
+ String copySuffix =
bucketFileName.substring(bucketFileName.lastIndexOf('_') + 1);
+ // Copy suffix is either a numeric counter or a 16-hex per-query
uniqueness tag.
+ // Hex-tagged files are unordered peers from concurrent writers on an
+ // non-atomic-rename FS, so there is no meaningful copy number to
assign — use 0.
+ int copyNumber = StringUtils.isNumeric(copySuffix) ?
Integer.parseInt(copySuffix) : 0;
int bucketId = Integer
.parseInt(bucketFileName.substring(0,
bucketFileName.indexOf('_')));
return new BucketMetaData(bucketId, copyNumber);
diff --git a/ql/src/java/org/apache/hadoop/hive/ql/metadata/Hive.java
b/ql/src/java/org/apache/hadoop/hive/ql/metadata/Hive.java
index 307cc48441f..ef74914401b 100644
--- a/ql/src/java/org/apache/hadoop/hive/ql/metadata/Hive.java
+++ b/ql/src/java/org/apache/hadoop/hive/ql/metadata/Hive.java
@@ -22,6 +22,7 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
@@ -164,6 +165,7 @@
import org.apache.hadoop.hive.metastore.utils.RetryUtilities;
import org.apache.hadoop.hive.ql.Context;
import org.apache.hadoop.hive.ql.ErrorMsg;
+import org.apache.hadoop.hive.ql.QueryPlan;
import org.apache.hadoop.hive.ql.ddl.database.drop.DropDatabaseDesc;
import org.apache.hadoop.hive.ql.ddl.table.AlterTableType;
import org.apache.hadoop.hive.ql.exec.AbstractFileMergeOperator;
@@ -5171,6 +5173,79 @@ private static String getPathName(int taskId) {
return Utilities.replaceTaskId("000000", taskId) + "_0";
}
+ /**
+ * Picks the destination {@link Path} for {@link #mvFile}, choosing between
a per-query
+ * uniqueness-tagged name (on filesystems without atomic rename-if-absent
semantics) and the
+ * legacy {@code _copy_N} counter-based picker.
+ *
+ * <p>On file systems without atomic rename-if-absent semantics (e.g. S3),
two concurrent inserts
+ * targeting the same new dynamic partition race in the counter-based picker
below: their
+ * {@code exists()} probes both fire before either PUT commits, both rename
to the same final
+ * key, and the second PUT silently overwrites the first (last writer wins,
no error surfaces).
+ * To eliminate the collision, on such filesystems we skip the counter-based
{@code _copy_N}
+ * picker entirely and use a per-query uniqueness tag (8-hex derived from
{@code hive.query.id})
+ * as the copy suffix, so two concurrent writers rename to distinct keys.
+ *
+ * <p>The uniqueness-tag path is only taken in the non-ACID rename branch
+ * ({@code taskId == -1 && isRenameAllowed && !isOverwrite}): ACID writers
already own unique
+ * taskIds, copy/copyFromLocal do not race on the destination filename, and
overwrite explicitly
+ * clears the target first.
+ */
+ private static Path pickDestFilePath(HiveConf conf, FileSystem sourceFs,
Path sourcePath, FileSystem destFs,
+ Path destDirPath, int taskId, boolean
isOverwrite, boolean isRenameAllowed)
+ throws IOException {
+
+ final String type = FilenameUtils.getExtension(sourcePath.getName());
+
+ // Strip off the file type, if any so we don't make:
+ // 000000_0.gz -> 000000_0.gz_copy_1
+ final String fullName = sourcePath.getName();
+
+ final String name;
+ if (taskId == -1) { // non-acid
+ name = FilenameUtils.getBaseName(sourcePath.getName());
+ } else { // acid
+ name = getPathName(taskId);
+ }
+
+ // In case of ACID, the file is ORC so the extension is not relevant and
should not be inherited.
+ Path destFilePath = new Path(destDirPath, taskId == -1 ? fullName : name);
+
+ final String uniqueCopySuffix =
+ // Only apply the unique suffix in case of files, as it's supposed to
handle file name collisions.
+ // When mvFile is called with a directory, we can fall back to the
original logic.
+ (taskId == -1 && isRenameAllowed && !isOverwrite &&
sourceFs.getFileStatus(sourcePath).isFile()
+ && FileUtils.isNonAtomicRenameFs(destFs))
+ ? QueryPlan.extractUniquenessTag(conf)
+ : null;
+
+ if (uniqueCopySuffix != null && !uniqueCopySuffix.isEmpty()) {
+ // Unstable-rename FS: use `name_copy_<tag>` unconditionally as the
destination. No
+ // exists()-probe loop, no _copy_N counter — the per-query tag alone is
enough to keep
+ // concurrent writers from colliding, and ParsedOutputFileName
recognizes the shape.
+ return new Path(destDirPath, name + Utilities.COPY_KEYWORD +
uniqueCopySuffix +
+ (!type.isEmpty() ? "." + type : ""));
+ }
+
+ /*
+ * The below loop may perform bad when the destination file already exists
and it has too many _copy_
+ * files as well. A desired approach was to call listFiles() and get a
complete list of files from
+ * the destination, and check whether the file exists or not on that list.
However, millions of files
+ * could live on the destination directory, and on concurrent situations,
this can cause OOM problems.
+ *
+ * I'll leave the below loop for now until a better approach is found.
+ */
+ for (int counter = 1; destFs.exists(destFilePath); counter++) {
+ if (isOverwrite) {
+ destFs.delete(destFilePath, false);
+ break;
+ }
+ destFilePath = new Path(destDirPath, name + (Utilities.COPY_KEYWORD +
counter) +
+ ((taskId == -1 && !type.isEmpty()) ? "." + type : ""));
+ }
+ return destFilePath;
+ }
+
/**
* <p>
* Moves a file from one {@link Path} to another. If {@code
isRenameAllowed} is true then the
@@ -5200,37 +5275,8 @@ private static String getPathName(int taskId) {
private static Path mvFile(HiveConf conf, FileSystem sourceFs, Path
sourcePath, FileSystem destFs, Path destDirPath,
boolean isSrcLocal, boolean isOverwrite, boolean
isRenameAllowed,
int taskId) throws IOException {
-
- // Strip off the file type, if any so we don't make:
- // 000000_0.gz -> 000000_0.gz_copy_1
- final String fullname = sourcePath.getName();
- final String name;
- if (taskId == -1) { // non-acid
- name = FilenameUtils.getBaseName(sourcePath.getName());
- } else { // acid
- name = getPathName(taskId);
- }
- final String type = FilenameUtils.getExtension(sourcePath.getName());
-
- // Incase of ACID, the file is ORC so the extension is not relevant and
should not be inherited.
- Path destFilePath = new Path(destDirPath, taskId == -1 ? fullname : name);
-
- /*
- * The below loop may perform bad when the destination file already exists
and it has too many _copy_
- * files as well. A desired approach was to call listFiles() and get a
complete list of files from
- * the destination, and check whether the file exists or not on that list.
However, millions of files
- * could live on the destination directory, and on concurrent situations,
this can cause OOM problems.
- *
- * I'll leave the below loop for now until a better approach is found.
- */
- for (int counter = 1; destFs.exists(destFilePath); counter++) {
- if (isOverwrite) {
- destFs.delete(destFilePath, false);
- break;
- }
- destFilePath = new Path(destDirPath, name + (Utilities.COPY_KEYWORD +
counter) +
- ((taskId == -1 && !type.isEmpty()) ? "." + type : ""));
- }
+ Path destFilePath = pickDestFilePath(conf, sourceFs, sourcePath, destFs,
destDirPath, taskId, isOverwrite,
+ isRenameAllowed);
if (isRenameAllowed) {
destFs.rename(sourcePath, destFilePath);
@@ -5242,7 +5288,7 @@ private static Path mvFile(HiveConf conf, FileSystem
sourceFs, Path sourcePath,
false, // overwrite destination
conf,
new DataCopyStatistics())) {
- LOG.error("Copy failed for source: " + sourcePath + " to destination:
" + destFilePath);
+ LOG.error("Copy failed for source: {} to destination: {}", sourcePath,
destFilePath);
throw new IOException("File copy failed.");
}
@@ -5250,10 +5296,10 @@ private static Path mvFile(HiveConf conf, FileSystem
sourceFs, Path sourcePath,
// have permission to delete the files in the source path. Ignore this
failure.
try {
if (!sourceFs.delete(sourcePath, true)) {
- LOG.warn("Delete source failed for source: " + sourcePath + " during
copy to destination: " + destFilePath);
+ LOG.warn("Delete source failed for source: {} during copy to
destination: {}", sourcePath, destFilePath);
}
} catch (Exception e) {
- LOG.warn("Delete source failed for source: " + sourcePath + " during
copy to destination: " + destFilePath, e);
+ LOG.warn("Delete source failed for source: {} during copy to
destination: {}", sourcePath, destFilePath, e);
}
}
return destFilePath;
diff --git
a/ql/src/test/org/apache/hadoop/hive/ql/exec/ParsedOutputFileNameTest.java
b/ql/src/test/org/apache/hadoop/hive/ql/exec/ParsedOutputFileNameTest.java
index 38fa535eeef..342ff337dae 100644
--- a/ql/src/test/org/apache/hadoop/hive/ql/exec/ParsedOutputFileNameTest.java
+++ b/ql/src/test/org/apache/hadoop/hive/ql/exec/ParsedOutputFileNameTest.java
@@ -121,6 +121,48 @@ public void testCopyAllParts() throws Exception {
Assert.assertEquals("tmp_(prefix)00001_02_copy_4",
p.makeFilenameWithCopyIndex(4));
}
+ /**
+ * On filesystems without atomic rename-if-absent semantics (S3 etc.), the
copy suffix
+ * carries a 16-hex per-query uniqueness tag instead of the numeric counter,
so concurrent
+ * writers rename to distinct destination keys.
+ */
+ @Test
+ public void testUniquenessTagAsCopySuffix() throws Exception {
+ ParsedOutputFileName p =
ParsedOutputFileName.parse("000001_0_copy_abcd1234deadbeef");
+ Assert.assertTrue(p.matches());
+ Assert.assertEquals("000001", p.getTaskId());
+ Assert.assertEquals("0", p.getAttemptId());
+ Assert.assertEquals("abcd1234deadbeef", p.getCopyIndex());
+ Assert.assertTrue(p.isCopyFile());
+ Assert.assertNull(p.getSuffix());
+ // Numeric-index renaming (used by legacy code paths) still works and
replaces the tag.
+ Assert.assertEquals("000001_0_copy_3", p.makeFilenameWithCopyIndex(3));
+ }
+
+ @Test
+ public void testUniquenessTagAsCopySuffixWithExtension() throws Exception {
+ ParsedOutputFileName p =
ParsedOutputFileName.parse("000001_0_copy_abcd1234deadbeef.snappy.orc");
+ Assert.assertTrue(p.matches());
+ Assert.assertEquals("000001", p.getTaskId());
+ Assert.assertEquals("0", p.getAttemptId());
+ Assert.assertEquals("abcd1234deadbeef", p.getCopyIndex());
+ Assert.assertTrue(p.isCopyFile());
+ Assert.assertEquals(".snappy.orc", p.getSuffix());
+ Assert.assertEquals("000001_0_copy_3", p.makeFilenameWithCopyIndex(3));
+ }
+
+ /**
+ * The copy-index group must reject shapes that are neither a 1..6 digit
counter nor an
+ * exactly-16-hex tag (e.g. non-hex characters, or a numeric tag longer than
6 digits).
+ */
+ @Test
+ public void testUniquenessTagShapeIsStrict() {
+ // 15 chars — matches neither branch.
+
Assert.assertNull(ParsedOutputFileName.parse("000001_0_copy_abcd1234deadbee").getCopyIndex());
+ // Non-hex character in a 16-char position.
+
Assert.assertNull(ParsedOutputFileName.parse("000001_0_copy_abcd1234deadbeez").getCopyIndex());
+ }
+
@Test
public void testNoMatch() {
ParsedOutputFileName p = ParsedOutputFileName.parse("ZfsLke");
diff --git
a/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFiles.java
b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFiles.java
index 0a7cab86275..5a3c844b745 100644
--- a/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFiles.java
+++ b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFiles.java
@@ -20,8 +20,10 @@
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hive.common.FileUtils;
import org.apache.hadoop.hive.conf.HiveConf;
import org.apache.hadoop.hive.conf.HiveConfForTest;
+import org.apache.hadoop.hive.ql.QueryPlan;
import org.apache.hadoop.hive.ql.session.SessionState;
import org.junit.BeforeClass;
import org.junit.Rule;
@@ -29,14 +31,19 @@
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
-import org.mockito.Mockito;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
@RunWith(Parameterized.class)
@@ -159,8 +166,8 @@ public void testCopyNewFilesOnDifferentFileSystem() throws
IOException {
Path targetPath = new Path(targetFolder.getRoot().getAbsolutePath());
// Simulate different filesystems by returning a different URI
- FileSystem spyTargetFs = Mockito.spy(targetPath.getFileSystem(hiveConf));
- Mockito.when(spyTargetFs.getUri()).thenReturn(URI.create("hdfs://" +
targetPath.toUri().getPath()));
+ FileSystem spyTargetFs = spy(targetPath.getFileSystem(hiveConf));
+ when(spyTargetFs.getUri()).thenReturn(URI.create("hdfs://" +
targetPath.toUri().getPath()));
try {
Hive.copyFiles(hiveConf, sourcePath, targetPath, spyTargetFs,
isSourceLocal, NO_ACID, false, null, false, false, false,
@@ -187,8 +194,8 @@ public void testCopyExistingFilesOnDifferentFileSystem()
throws IOException {
Path targetPath = new Path(targetFolder.getRoot().getAbsolutePath());
// Simulate different filesystems by returning a different URI
- FileSystem spyTargetFs = Mockito.spy(targetPath.getFileSystem(hiveConf));
- Mockito.when(spyTargetFs.getUri()).thenReturn(URI.create("hdfs://" +
targetPath.toUri().getPath()));
+ FileSystem spyTargetFs = spy(targetPath.getFileSystem(hiveConf));
+ when(spyTargetFs.getUri()).thenReturn(URI.create("hdfs://" +
targetPath.toUri().getPath()));
try {
Hive.copyFiles(hiveConf, sourcePath, targetPath, spyTargetFs,
isSourceLocal, NO_ACID, false, null,
@@ -228,4 +235,73 @@ public void testCopyExistingFilesOnDifferentFileSystem()
throws IOException {
assertTrue(spyTargetFs.exists(new Path(targetPath, "000000_0_copy_1.gz")));
assertTrue(spyTargetFs.exists(new Path(targetPath, "000001_0_copy_1.gz")));
}
+
+ /**
+ * When two concurrent writers stage a file with the same inner filename
(e.g. {@code 000000_0})
+ * into the same destination directory on an S3-like filesystem, mvFile must
pick distinct
+ * destination keys so the second writer does not silently overwrite the
first. Both files
+ * must land under distinct {@code 000000_0_copy_<hex>} names — no plain
{@code 000000_0}, no
+ * numeric {@code _copy_N}.
+ *
+ * <p>Covers the two moving parts individually since the full rename-branch
path in
+ * {@link Hive#copyFiles} requires src and dest FileSystems to compare equal
AND the dest
+ * scheme to be flagged non-atomic-rename, which is not easily synthesizable
with
+ * LocalFileSystem in a JUnit environment:
+ * <ol>
+ * <li>{@link FileUtils#isNonAtomicRenameFs(FileSystem)} recognizes
S3-family schemes on the URI
+ * and rejects HDFS / local schemes.</li>
+ * <li>Two distinct {@code hive.query.id} values map to two distinct 8-hex
uniqueness tags
+ * — the compact per-query identifier that mvFile appends when the
destination
+ * filesystem is a non-atomic-rename one. Confirms the tag is stable
for a given
+ * queryId, and that the tag's shape (8 hex chars) matches the
copy-suffix group in
+ * {@link org.apache.hadoop.hive.ql.exec.ParsedOutputFileName}'s
regex.</li>
+ * </ol>
+ */
+ @Test
+ public void testUniquenessTagAndUnstableFsGating() throws IOException {
+ // (1) non-atomic-rename filesystem detection via URI scheme
+ FileSystem localFs = new
Path(targetFolder.getRoot().getAbsolutePath()).getFileSystem(hiveConf);
+ assertFalse("local FS is atomic-rename",
FileUtils.isNonAtomicRenameFs(localFs));
+ assertFalse("null fs is not flagged",
FileUtils.isNonAtomicRenameFs((FileSystem) null));
+
+ for (String scheme : new String[] {"s3a", "s3n", "s3", "gs", "abfs",
"abfss", "wasb", "wasbs"}) {
+ FileSystem spy = spy(localFs);
+ when(spy.getUri()).thenReturn(URI.create(scheme + ":///bucket/path"));
+ assertTrue(scheme + " must be flagged non-atomic-rename",
+ FileUtils.isNonAtomicRenameFs(spy));
+ }
+ for (String scheme : new String[] {"hdfs", "file", "ofs", "adl"}) {
+ FileSystem spy = spy(localFs);
+ when(spy.getUri()).thenReturn(URI.create(scheme + ":///whatever"));
+ assertFalse(scheme + " must not be flagged non-atomic-rename",
+ FileUtils.isNonAtomicRenameFs(spy));
+ }
+
+ // (2) uniqueness tag: the 16-hex most-significant-bits half of the UUID
at the tail of
+ // queryId (QueryPlan.makeQueryId → "<user>_<timestamp>_<uuid>"; see
+ // QueryPlan.extractUniquenessTag). Distinct UUIDs → distinct tags.
+ hiveConf.setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
+ "lbodor_20260101120000_f47ac10b-58cc-4372-a567-0e02b2c3d479");
+ String tag1 = QueryPlan.extractUniquenessTag(hiveConf);
+ hiveConf.setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
+ "lbodor_20260101120001_9c8a44f1-e2b3-4a1c-9d3e-000000000000");
+ String tag2 = QueryPlan.extractUniquenessTag(hiveConf);
+
+ assertEquals("MSB half of the UUID at the tail", "f47ac10b58cc4372", tag1);
+ assertEquals("MSB half of the UUID at the tail", "9c8a44f1e2b34a1c", tag2);
+ assertTrue("tag1 must match <16-hex>: " + tag1,
tag1.matches("[0-9a-f]{16}"));
+ assertTrue("tag2 must match <16-hex>: " + tag2,
tag2.matches("[0-9a-f]{16}"));
+ assertNotEquals("distinct queryIds must produce distinct tags", tag1,
tag2);
+
+ // Missing queryId → hard failure (mvFile's non-atomic-rename branch must
not silently
+ // fall back to a shared filename when the query state is absent).
+ hiveConf.unset(HiveConf.ConfVars.HIVE_QUERY_ID.varname);
+ try {
+ QueryPlan.extractUniquenessTag(hiveConf);
+ fail("extractUniquenessTag must throw when hive.query.id is unset");
+ } catch (IllegalStateException expected) {
+ assertTrue("exception message must mention hive.query.id: " +
expected.getMessage(),
+ expected.getMessage() != null &&
expected.getMessage().contains("hive.query.id"));
+ }
+ }
}
diff --git
a/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFilesFakeS3.java
b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFilesFakeS3.java
new file mode 100644
index 00000000000..b618095a2fe
--- /dev/null
+++
b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestHiveCopyFilesFakeS3.java
@@ -0,0 +1,299 @@
+/*
+ * 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.hadoop.hive.ql.metadata;
+
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RawLocalFileSystem;
+import org.apache.hadoop.hive.common.FileUtils;
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.conf.HiveConfForTest;
+import org.apache.hadoop.hive.ql.exec.ParsedOutputFileName;
+import org.apache.hadoop.hive.ql.io.AcidUtils;
+import org.apache.hadoop.hive.ql.session.SessionState;
+import org.apache.hadoop.hive.ql.util.FakeS3FileSystem;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * End-to-end tests for {@link Hive#copyFiles} through the
non-atomic-rename-FS branch of
+ * {@link Hive}'s move logic. Registers a synthetic {@code fakes3://} scheme
backed by
+ * {@link RawLocalFileSystem} (so real files land under a JUnit {@link
TemporaryFolder})
+ * and appends it directly to {@link FileUtils#NON_ATOMIC_RENAME_SCHEMES} for
the duration of
+ * this test class so {@link FileUtils#isNonAtomicRenameFs} treats it as a
non-atomic-rename
+ * FS. The scheme is removed in {@link #tearDownClass()} so no other test sees
it.
+ *
+ * <p>What this covers that the mockito-spy tests in {@link TestHiveCopyFiles}
do not:
+ * an actual rename() call is made through the tag-suffix branch of
+ * {@link Hive#pickDestFilePath}, and the resulting on-disk layout is asserted.
+ */
+class TestHiveCopyFilesFakeS3 {
+
+ /** Scheme registered as {@code fs.fakes3.impl} for the duration of these
tests. */
+ private static final String FAKE_SCHEME = FakeS3FileSystem.SCHEME;
+
+ private static HiveConf hiveConf;
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @BeforeClass
+ public static void setUpClass() {
+ hiveConf = new HiveConfForTest(TestHiveCopyFilesFakeS3.class);
+ // Register the fake scheme's FileSystem impl. Cache off so each test gets
a fresh
+ // instance rooted under its own TemporaryFolder without cross-test
leakage.
+ hiveConf.setClass("fs." + FAKE_SCHEME + ".impl", FakeS3FileSystem.class,
FileSystem.class);
+ hiveConf.setBoolean("fs." + FAKE_SCHEME + ".impl.disable.cache", true);
+ // Have FileUtils.isNonAtomicRenameFs treat our fake scheme as a
non-atomic-rename FS.
+ // This is a JVM-global mutation of a production static — we undo it in
tearDownClass
+ // so no test that runs after this class sees fakes3 in the set.
+ FileUtils.NON_ATOMIC_RENAME_SCHEMES.add(FAKE_SCHEME);
+ SessionState.start(hiveConf);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ FileUtils.NON_ATOMIC_RENAME_SCHEMES.remove(FAKE_SCHEME);
+ }
+
+ @Before
+ public void setUp() {
+ // Every test needs a fresh hive.query.id so computeUniquenessTag produces
a real tag.
+ hiveConf.setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
+ "test_" + System.nanoTime() + "_f47ac10b-58cc-4372-a567-0e02b2c3d479");
+ }
+
+ /**
+ * Builds a Path in the {@code fakes3://} namespace that points at the given
local
+ * subdirectory of the JUnit temp root. We use the local path as the URI
path so the
+ * underlying RawLocalFileSystem writes/reads real files there.
+ */
+ private Path fakes3Path(String subdir) throws IOException {
+ java.io.File dir = tmp.newFolder(subdir);
+ return new Path(URI.create(FAKE_SCHEME + "://" + dir.getAbsolutePath()));
+ }
+
+ /**
+ * fakes3 must be recognized as a non-atomic-rename FS once
+ * {@link #setUpClass()} has appended it to
+ * {@link FileUtils#NON_ATOMIC_RENAME_SCHEMES}; a plain {@code file://}
filesystem
+ * must not be.
+ */
+ @Test
+ public void fakes3IsFlaggedNonAtomicRename() throws IOException {
+ Path fakePath = fakes3Path("gate");
+ FileSystem fakeFs = fakePath.getFileSystem(hiveConf);
+
+ assertEquals(FAKE_SCHEME, fakeFs.getUri().getScheme());
+ assertTrue("fakes3 must be non-atomic-rename",
FileUtils.isNonAtomicRenameFs(fakeFs));
+
+ FileSystem localFs = new
Path(tmp.getRoot().getAbsolutePath()).getFileSystem(hiveConf);
+ assertFalse("local FS must not be flagged",
FileUtils.isNonAtomicRenameFs(localFs));
+ }
+
+ /**
+ * A single-file rename into a fresh destination under {@code fakes3://}
must land at
+ * {@code <name>_copy_<16-hex>} (uniqueness-tag suffix), not at {@code
<name>} — even
+ * though the target directory is empty. This is the whole point of the tag
branch:
+ * skip the exists() probe and stamp the name unconditionally so concurrent
writers
+ * cannot race on the same key.
+ */
+ @Test
+ public void singleFileRenameUsesUniquenessTagSuffix() throws Exception {
+ Path srcDir = fakes3Path("src");
+ Path dstDir = fakes3Path("dst");
+ FileSystem fs = dstDir.getFileSystem(hiveConf);
+ fs.create(new Path(srcDir, "000000_0")).close();
+
+ // fakes3 is flagged non-atomic-rename, so Hive.pickDestFilePath takes the
+ // tag branch: append _copy_<16-hex> unconditionally, skipping the
exists() probe.
+ Hive.copyFiles(hiveConf, srcDir, dstDir, fs, false, false, false, null,
+ false, false, false, false);
+
+ FileStatus[] listed = fs.listStatus(dstDir);
+ assertEquals("one output file expected", 1, listed.length);
+ String name = listed[0].getPath().getName();
+ // Reuse the production regexes from AcidUtils so the test tracks whatever
+ // the readers on the other end accept, and tighten to require the 16-hex
+ // uniqueness tag (not the numeric _copy_N fallback).
+ assertHas16HexUniquenessTag(name);
+ assertFalse("no plain 000000_N leaf allowed on non-atomic-rename FS: " +
name,
+ AcidUtils.ORIGINAL_PATTERN.matcher(name).matches());
+ }
+
+ /**
+ * Two staged files that share an inner filename must land at two distinct
+ * {@code _copy_<16-hex>} keys — never at plain {@code 000000_0} vs
+ * {@code 000000_0_copy_1}. Each of the two copyFiles calls runs under its
own
+ * hive.query.id, so the two tags differ and the two on-disk keys must differ
+ * too. That's the property that guards against silent overwrite on S3A.
+ */
+ @Test
+ public void twoStagedFilesLandAtDistinctKeys() throws Exception {
+ Path srcDir = fakes3Path("src");
+ Path dstDir = fakes3Path("dst");
+ FileSystem fs = dstDir.getFileSystem(hiveConf);
+
+ // Call #1 — queryId T1 set by @Before.
+ fs.create(new Path(srcDir, "000000_0")).close();
+ Hive.copyFiles(hiveConf, srcDir, dstDir, fs, false, false, false, null,
+ false, false, false, false);
+
+ // Stage a second file with the same base name and copy again — since
srcDir was
+ // consumed by the previous move, recreate the source folder.
+ fs.mkdirs(srcDir);
+ fs.create(new Path(srcDir, "000000_0")).close();
+ // Call #2 — different queryId → different tag → distinct destination name.
+ hiveConf.setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
+ "test_" + System.nanoTime() + "_9c8a44f1-e2b3-4a1c-9d3e-000000000000");
+ Hive.copyFiles(hiveConf, srcDir, dstDir, fs, false, false, false, null,
+ false, false, false, false);
+
+ FileStatus[] listed = fs.listStatus(dstDir);
+ assertEquals("two output files expected", 2, listed.length);
+ for (FileStatus s : listed) {
+ String name = s.getPath().getName();
+ // Nothing may be named plain 000000_N — that's the silent-overwrite
failure mode.
+ assertFalse("no plain 000000_N leaf allowed on non-atomic-rename FS: " +
name,
+ AcidUtils.ORIGINAL_PATTERN.matcher(name).matches());
+ assertHas16HexUniquenessTag(name);
+ }
+ assertNotEquals("two writers must land at distinct keys",
+ listed[0].getPath().getName(), listed[1].getPath().getName());
+ }
+
+ /**
+ * Ten threads all call {@link Hive#copyFiles} concurrently into the SAME
+ * destination directory on {@code fakes3://}, each staging its own
+ * {@code 000000_0} under a per-thread source directory. Each thread runs
with
+ * its own {@link HiveConf} clone and its own {@code hive.query.id}, so
+ * {@link Hive#computeUniquenessTag} produces ten distinct 16-hex tags. The
+ * invariants:
+ * <ol>
+ * <li>Exactly 10 files end up in the destination (nothing was silently
+ * overwritten on rename).</li>
+ * <li>Every filename matches {@link AcidUtils#ORIGINAL_PATTERN_COPY}
+ * (the {@code _copy_<tag>} shape) and none matches
+ * {@link AcidUtils#ORIGINAL_PATTERN} (no plain {@code 000000_N}).</li>
+ * <li>All 10 filenames are distinct.</li>
+ * </ol>
+ * This is the actual multi-writer race the tag branch of
+ * {@link Hive#pickDestFilePath} is designed to protect against on S3A —
+ * driven from Java threads directly rather than through the planner.
+ */
+ @Test
+ public void tenConcurrentCopiesLandAtDistinctTaggedKeys() throws Exception {
+ final int threads = 10;
+ // Shared destination for every writer — this is what makes it a race.
+ final Path dstDir = fakes3Path("dst");
+ final FileSystem fs = dstDir.getFileSystem(hiveConf);
+
+ // Stage each thread's source in its own directory. All ten source files
+ // are named 000000_0 — the collision we're testing is on the DESTINATION
+ // side, where the tag branch is expected to make the ten same-named
+ // inputs land at ten distinct keys. The per-thread source dir is a
+ // mechanical necessity (rename() consumes its source, so multiple threads
+ // can't share one physical file), not a collision-avoidance measure.
+ final Path[] srcDirs = new Path[threads];
+ final HiveConf[] confs = new HiveConf[threads];
+ for (int i = 0; i < threads; i++) {
+ srcDirs[i] = fakes3Path("src" + i);
+ fs.create(new Path(srcDirs[i], "000000_0")).close();
+
+ confs[i] = new HiveConf(hiveConf);
+ confs[i].setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
+ "test_concurrent_" + i + "_" + java.util.UUID.randomUUID());
+ }
+
+ final SessionState parentSession = SessionState.get();
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try {
+ List<Future<Void>> futures = new java.util.ArrayList<>();
+ for (int i = 0; i < threads; i++) {
+ final int idx = i;
+ futures.add(pool.submit(() -> {
+ SessionState.setCurrentSessionState(parentSession);
+ Hive.copyFiles(confs[idx], srcDirs[idx], dstDir, fs, false, false,
false, null,
+ false, false, false, false);
+ return null;
+ }));
+ }
+ for (Future<Void> f : futures) {
+ // Surface the first exception rather than swallowing it as a timeout.
+ f.get(60, TimeUnit.SECONDS);
+ }
+ } finally {
+ pool.shutdown();
+ assertTrue("executor did not terminate", pool.awaitTermination(30,
TimeUnit.SECONDS));
+ }
+
+ FileStatus[] listed = fs.listStatus(dstDir);
+ assertEquals("expected exactly " + threads + " output files, got: "
+ + java.util.Arrays.toString(listed), threads, listed.length);
+ Set<String> names = new HashSet<>();
+ for (FileStatus s : listed) {
+ String name = s.getPath().getName();
+ assertFalse("no plain 000000_N leaf allowed on non-atomic-rename FS: " +
name,
+ AcidUtils.ORIGINAL_PATTERN.matcher(name).matches());
+ assertHas16HexUniquenessTag(name);
+ assertTrue("duplicate destination filename after concurrent copyFiles: "
+ name,
+ names.add(name));
+ }
+ }
+
+ /**
+ * Asserts that {@code name} is a copy-suffixed filename whose suffix is the
+ * 16-hex per-query uniqueness tag (as opposed to the numeric {@code _copy_N}
+ * fallback that AcidUtils.ORIGINAL_PATTERN_COPY also accepts). Parses via
+ * ParsedOutputFileName so we track whatever it accepts.
+ */
+ private static void assertHas16HexUniquenessTag(String name) {
+ // Baseline: must match the copy pattern the readers accept.
+ assertTrue("must match _copy_ pattern, got: " + name,
+ AcidUtils.ORIGINAL_PATTERN_COPY.matcher(name).matches());
+ // Tighten: the suffix must be exactly 16 hex chars, not the numeric
fallback.
+ ParsedOutputFileName parsed = ParsedOutputFileName.parse(name);
+ assertTrue("ParsedOutputFileName should recognize " + name,
parsed.matches());
+ assertTrue("expected copy suffix on " + name, parsed.isCopyFile());
+ String tag = parsed.getCopyIndex();
+ assertEquals("copy suffix must be 16 chars long (uniqueness tag, not
numeric fallback), got: " + tag,
+ 16, tag.length());
+ assertTrue("copy suffix must be lowercase-or-uppercase hex, got: " + tag,
+ tag.matches("[0-9a-fA-F]{16}"));
+ }
+}
diff --git
a/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestInsertCopySuffixOnFakeS3.java
b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestInsertCopySuffixOnFakeS3.java
new file mode 100644
index 00000000000..f6b5e0c9242
--- /dev/null
+++
b/ql/src/test/org/apache/hadoop/hive/ql/metadata/TestInsertCopySuffixOnFakeS3.java
@@ -0,0 +1,442 @@
+/*
+ * 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.hadoop.hive.ql.metadata;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.LocatedFileStatus;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RawLocalFileSystem;
+import org.apache.hadoop.fs.RemoteIterator;
+import org.apache.hadoop.hive.common.FileUtils;
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.ql.TxnCommandsBaseForTests;
+import org.apache.hadoop.hive.ql.exec.AbstractFileMergeOperator;
+import org.apache.hadoop.hive.ql.exec.ParsedOutputFileName;
+import org.apache.hadoop.hive.ql.io.AcidUtils;
+import org.apache.hadoop.hive.ql.util.FakeS3FileSystem;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Driver-level end-to-end test for the copy-suffix logic in
+ * {@link Hive#mvFile} on a non-atomic-rename filesystem.
+ *
+ * <p>Runs real {@code INSERT INTO} and {@code INSERT INTO ... UNION ALL ...}
+ * queries through the {@link org.apache.hadoop.hive.ql.Driver} and inspects
+ * the resulting on-disk layout of tables whose LOCATION is a
+ * synthetic {@code fakes3://} URI. The scheme is registered as an alias for
+ * {@link RawLocalFileSystem} (so files still live under {@code test.tmp.dir}
+ * on the local disk) and appended to {@link
FileUtils#NON_ATOMIC_RENAME_SCHEMES}
+ * so {@link FileUtils#isNonAtomicRenameFs} treats it like S3A.
+ *
+ * <p>What this covers over {@link TestHiveCopyFilesFakeS3}: the whole
+ * planner/executor/MoveTask path is exercised, not just {@code Hive.copyFiles}
+ * in isolation. Anything that changes how output files land in the table
+ * directory (e.g. FileSinkOperator, MoveTask, UnionProcFactory) is on the
+ * hook here.
+ */
+class TestInsertCopySuffixOnFakeS3 extends TxnCommandsBaseForTests {
+
+ private static final String FAKE_SCHEME = FakeS3FileSystem.SCHEME;
+ private static final String TEST_DATA_DIR = new
File(System.getProperty("java.io.tmpdir")
+ + File.separator + TestInsertCopySuffixOnFakeS3.class.getCanonicalName()
+ + "-" + System.currentTimeMillis()).getPath().replaceAll("\\\\", "/");
+
+ @BeforeAll
+ static void addFakeSchemeToUnstableSet() {
+ FileUtils.NON_ATOMIC_RENAME_SCHEMES.add(FAKE_SCHEME);
+ }
+
+ @AfterAll
+ static void removeFakeSchemeFromUnstableSet() {
+ FileUtils.NON_ATOMIC_RENAME_SCHEMES.remove(FAKE_SCHEME);
+ }
+
+ @Override
+ protected String getTestDataDir() {
+ return TEST_DATA_DIR;
+ }
+
+ @Override
+ protected void initHiveConf() {
+ super.initHiveConf();
+ // Register the fake scheme's FileSystem impl for this session.
+ hiveConf.setClass("fs." + FAKE_SCHEME + ".impl", FakeS3FileSystem.class,
FileSystem.class);
+ hiveConf.setBoolean("fs." + FAKE_SCHEME + ".impl.disable.cache", true);
+ // Non-strict managed tables so we can point tables outside the warehouse.
+ HiveConf.setBoolVar(hiveConf,
HiveConf.ConfVars.HIVE_STRICT_MANAGED_TABLES, false);
+ HiveConf.setBoolVar(hiveConf, HiveConf.ConfVars.CREATE_TABLES_AS_ACID,
false);
+ HiveConf.setBoolVar(hiveConf,
HiveConf.ConfVars.HIVE_CREATE_TABLES_AS_INSERT_ONLY, false);
+ HiveConf.setVar(hiveConf, HiveConf.ConfVars.DYNAMIC_PARTITIONING_MODE,
"nonstrict");
+ // UNION-ALL: keep subdirs unflattened so we exercise the layout that
production S3 workloads see.
+ HiveConf.setBoolVar(hiveConf,
HiveConf.ConfVars.HIVE_TEZ_UNION_FLATTEN_SUBDIRECTORIES, false);
+ }
+
+ @AfterEach
+ void dropAllTestTables() throws Exception {
+ for (String t : new String[] {"insert_into_fakes3", "union_all_fakes3",
"union_all_dyn_part_fakes3",
+ "insert_only_fakes3", "full_acid_fakes3", "union_src"}) {
+ try {
+ runQuery("drop table if exists " + t);
+ } catch (Exception ignore) {
+ // don't let a residual-drop failure hide the real test failure
+ }
+ }
+ }
+
+ @Override
+ protected void setUpSchema() {
+ // Override the parent's schema — we don't need the ACID/bucketed
+ // TxnCommandsBaseForTests fixture tables; each test creates its own
+ // external table at a fakes3:// location.
+ }
+
+ @Override
+ protected void dropTables() {
+ // The parent's dropTables would try to drop the schema tables that we
+ // never created; skip.
+ }
+
+ private List<String> runQuery(String stmt) throws Exception {
+ hiveConf.setVar(HiveConf.ConfVars.HIVE_QUERY_ID,
org.apache.hadoop.hive.ql.QueryPlan.makeQueryId());
+ d.run(stmt);
+ List<String> rs = new ArrayList<>();
+ d.getResults(rs);
+ return rs;
+ }
+
+ /**
+ * Absolute local path under the test temp dir, wrapped in a {@code
fakes3://}
+ * URI. Files land on the local disk at {@code path}, but Hive resolves the
+ * URI to our FakeS3FileSystem and applies the non-atomic-rename logic.
+ */
+ private String fakeS3Location(String subdir) {
+ return FAKE_SCHEME + "://" + TEST_DATA_DIR + "/" + subdir;
+ }
+
+ private Path fakeS3Path(String subdir) {
+ return new Path(URI.create(fakeS3Location(subdir)));
+ }
+
+ /** Collect every file under {@code root} into a flat list of relative
paths. */
+ private List<String> listFilesRelative(Path root) throws IOException {
+ FileSystem fs = root.getFileSystem(hiveConf);
+ List<String> paths = new ArrayList<>();
+ if (!fs.exists(root)) {
+ return paths;
+ }
+ RemoteIterator<LocatedFileStatus> it = fs.listFiles(root, true);
+ while (it.hasNext()) {
+ LocatedFileStatus s = it.next();
+ if (s.isFile() && !s.getPath().getName().startsWith("_") &&
!s.getPath().getName().startsWith(".")) {
+ String full = s.getPath().toUri().getPath();
+ String rootPath = root.toUri().getPath();
+ paths.add(full.startsWith(rootPath) ?
full.substring(rootPath.length()) : full);
+ }
+ }
+ return paths;
+ }
+
+ /**
+ * Simple {@code INSERT INTO} into a non-ACID external ORC table on
+ * fakes3://. Every output file must carry the 16-hex uniqueness-tag copy
+ * suffix — no plain {@code 000000_N}.
+ */
+ @Test
+ void testInsertIntoNonAcidExternalOnFakeS3() throws Exception {
+ String tbl = "insert_into_fakes3";
+ Path loc = fakeS3Path(tbl);
+
+ runQuery(
+ "create external table " + tbl + " (a int, b int) stored as orc "
+ + "location '" + fakeS3Location(tbl) + "' "
+ + "tblproperties
('transactional'='false','external.table.purge'='true')");
+
+ runQuery("insert into " + tbl + " values (1, 10), (2, 20), (3, 30)");
+
+ List<String> files = listFilesRelative(loc);
+ assertFalse(files.isEmpty(), "insert produced no files under " + loc);
+ for (String rel : files) {
+ String name = rel.substring(rel.lastIndexOf('/') + 1);
+ assertHas16HexUniquenessTag(name);
+ assertFalse(AcidUtils.ORIGINAL_PATTERN.matcher(name).matches(),
+ "no plain 000000_N leaf allowed on non-atomic-rename FS: " + rel);
+ }
+
+ assertRowCount(tbl, 3);
+
+ convertToFullAcidAndAssertRowCount(tbl, 3);
+ }
+
+ /**
+ * {@code INSERT INTO ... UNION ALL ...} on a non-ACID external ORC
+ * table. Every output file must carry the tag suffix, no two leaves may
+ * share a name (that's the anti-silent-overwrite guarantee), and the row
+ * count must be 3. The number of output files is a planner choice — one
+ * combined mapper vs one-per-branch — so we do NOT assert on it.
+ * hive.tez.union.flatten.subdirectories=false is set, so the
+ * HIVE_UNION_SUBDIR_N layout is preserved when the planner does emit it.
+ */
+ @Test
+ void testUnionAllInsertOnFakeS3() throws Exception {
+ String tbl = "union_all_fakes3";
+ Path loc = fakeS3Path(tbl);
+
+ runQuery(
+ "create external table " + tbl + " (a int, b int) stored as orc "
+ + "location '" + fakeS3Location(tbl) + "' "
+ + "tblproperties
('transactional'='false','external.table.purge'='true')");
+
+ createUnionSrc();
+ runQuery(
+ "insert into " + tbl + " "
+ + "select k as a, sum(v) as b from union_src where k = 1 group by k
union all "
+ + "select k as a, sum(v) as b from union_src where k = 2 group by k
union all "
+ + "select k as a, sum(v) as b from union_src where k = 3 group by k");
+
+ assertUnionSubdirLayoutAt(loc, /* partitionSegment */ null);
+
+ assertRowCount(tbl, 3);
+
+ // FIXME: convertToFullAcidAndAssertRowCount should pass after HIVE-29798
is fixed
+ assertThrows(Exception.class,
+ () -> convertToFullAcidAndAssertRowCount(tbl, 3),
+ "expected read-back after CONVERT TO ACID to fail until HIVE-29798 is
fixed");
+ }
+
+ /**
+ * Same 3-way UNION ALL, but this time the target is a partitioned external
+ * table and the UNION branches all land in the same new dynamic partition.
+ * This is the shape the HIVE-28822 concurrent-insert repro compressed into a
+ * single statement. Every leaf must still be tagged; no writer may clobber
+ * another.
+ */
+ @Test
+ void testUnionAllInsertToPartitionedOnFakeS3() throws Exception {
+ String tbl = "union_all_dyn_part_fakes3";
+ Path loc = fakeS3Path(tbl);
+
+ runQuery(
+ "create external table " + tbl + " (a int) partitioned by (b int)
stored as orc "
+ + "location '" + fakeS3Location(tbl) + "' "
+ + "tblproperties
('transactional'='false','external.table.purge'='true')");
+
+ createUnionSrc();
+ runQuery(
+ "insert into " + tbl + " partition (b) "
+ + "select k as a, 2 as b from union_src where k = 1 group by k union
all "
+ + "select k as a, 2 as b from union_src where k = 2 group by k union
all "
+ + "select k as a, 2 as b from union_src where k = 3 group by k");
+
+ assertUnionSubdirLayoutAt(loc, /* partitionSegment */ "b=2");
+
+ assertRowCount(tbl, 3);
+
+ convertToFullAcidAndAssertRowCount(tbl, 3);
+ }
+
+ /**
+ * Materialize a small staging table {@code union_src} so each UNION branch
+ * does a real group-by scan; that keeps the planner from constant-folding
+ * the branches into a single mapper and preserves the per-branch
+ * HIVE_UNION_SUBDIR_<N>/ layout at final-move time.
+ */
+ private void createUnionSrc() throws Exception {
+ runQuery("drop table if exists union_src");
+ runQuery("create table union_src (k int, v int) stored as orc "
+ + "tblproperties ('transactional'='false')");
+ runQuery("insert into union_src values (1, 10), (2, 20), (3, 30)");
+ }
+
+ /**
+ * Common tail of the two UNION-ALL tests. Lists every file under {@code loc}
+ * and asserts:
+ * <ol>
+ * <li>Some files were produced (union insert didn't silently no-op).</li>
+ * <li>If {@code partitionSegment} is non-null, every leaf's path contains
+ * {@code /<partitionSegment>/} (e.g. {@code /b=2/}).</li>
+ * <li>Every leaf's parent directory starts with
+ * {@link AbstractFileMergeOperator#UNION_SUDBIR_PREFIX}, and there are
+ * exactly 3 such distinct parent dirs (one per UNION branch).</li>
+ * <li>Every leaf name is a plain writer name (matches
+ * {@link AcidUtils#ORIGINAL_PATTERN}) with NO {@code _copy_<tag>}
suffix:
+ * with per-branch subdirs there is no target-side collision to defend
+ * against, so {@code pickDestFilePath} does not stamp a uniqueness tag
+ * on top. The HIVE_UNION_SUBDIR_<N>/ separation is the anti-collision
+ * mechanism here.</li>
+ * </ol>
+ */
+ private void assertUnionSubdirLayoutAt(Path loc, String partitionSegment)
throws IOException {
+ List<String> files = listFilesRelative(loc);
+ assertFalse(files.isEmpty(), "union-all insert produced no files under " +
loc);
+
+ Set<String> subdirs = new HashSet<>();
+ for (String rel : files) {
+ if (partitionSegment != null) {
+ assertTrue(rel.contains("/" + partitionSegment + "/"),
+ "output must live under " + partitionSegment + " partition: " +
rel);
+ }
+ int lastSlash = rel.lastIndexOf('/');
+ int prevSlash = rel.lastIndexOf('/', lastSlash - 1);
+ String parentDir = rel.substring(prevSlash + 1, lastSlash);
+
assertTrue(parentDir.startsWith(AbstractFileMergeOperator.UNION_SUDBIR_PREFIX),
+ "union branch's leaf must live under a HIVE_UNION_SUBDIR_<N>/ dir: "
+ rel);
+ subdirs.add(parentDir);
+
+ String name = rel.substring(lastSlash + 1);
+ assertTrue(AcidUtils.ORIGINAL_PATTERN.matcher(name).matches(),
+ "leaf must be a plain writer name (no _copy_<tag> suffix expected):
" + rel);
+ ParsedOutputFileName parsed = ParsedOutputFileName.parse(name);
+ assertTrue(parsed.matches(), "ParsedOutputFileName should recognize " +
name);
+ assertFalse(parsed.isCopyFile(),
+ "leaf must NOT carry a copy suffix — HIVE_UNION_SUBDIR_ layout
already"
+ + " isolates concurrent writers, so no uniqueness tag is
expected: " + rel);
+ }
+ assertEquals(3, subdirs.size(),
+ "each of the 3 UNION branches must have its own
HIVE_UNION_SUBDIR_<N>/: " + subdirs);
+ }
+
+ /**
+ * Insert into a micromanaged (insert-only ACID) table on fakes3://. Unlike
the
+ * non-ACID cases above, MM tables get their per-writer uniqueness from the
+ * writeId-scoped {@code delta_<writeId>_<writeId>_<stmt>/} subdirectory
rather
+ * than from a per-query filename tag: MoveTask short-circuits into the delta
+ * layout without going through {@link Hive#copyFiles} at all. So on a
non-atomic-rename
+ * FS the leaves inside the delta directory are the plain writer-emitted
names
+ * (e.g. {@code 000000_N}) — that's expected, and the delta_ dir alone keeps
+ * two concurrent MM writers from clobbering each other.
+ *
+ * <p>Two concurrent MM inserts would get separate {@code delta_<writeId>}
dirs,
+ * so a plain {@code 000000_0} inside each is safe. We can't easily
reproduce a
+ * concurrent write here, but we can pin the current single-insert layout:
+ * every leaf lives under a delta_ subdirectory, and the row count matches.
+ */
+ @Test
+ void testInsertIntoMicromanagedOnFakeS3LandsUnderDeltaSubdir() throws
Exception {
+ String tbl = "insert_only_fakes3";
+ Path loc = fakeS3Path(tbl);
+ runQuery(
+ "create table " + tbl + " (a int, b int) stored as orc "
+ + "location '" + fakeS3Location(tbl) + "' "
+ + "tblproperties
('transactional'='true','transactional_properties'='insert_only')");
+
+ runQuery("insert into " + tbl + " values (1, 10), (2, 20), (3, 30)");
+
+ List<String> files = listFilesRelative(loc);
+ assertFalse(files.isEmpty(), "MM insert produced no files under " + loc);
+
+ // Every leaf must live under a delta_* directory (that's the MM
per-writeId
+ // uniqueness scope — the analogue of the per-query tag for the non-ACID
+ // cases). The leaf name itself is the writer's plain 000000_N.
+ for (String rel : files) {
+ assertTrue(rel.contains("/" + AcidUtils.DELTA_PREFIX),
+ "MM leaf must live under a delta_* subdir: " + rel);
+ String name = rel.substring(rel.lastIndexOf('/') + 1);
+ assertTrue(AcidUtils.ORIGINAL_PATTERN.matcher(name).matches(),
+ "MM leaf must be a plain writer name (000000_N): " + rel);
+ }
+
+ assertRowCount(tbl, 3);
+
+ convertToFullAcidAndAssertRowCount(tbl, 3);
+ }
+
+ /**
+ * Insert into a full ACID (transactional=true, default
transactional_properties)
+ * managed table on fakes3://. Same story as the MM case: MoveTask
short-circuits
+ * into the {@code delta_<writeId>_<writeId>_<stmt>/} layout without going
through
+ * {@link Hive#copyFiles}, so the per-query filename tag never fires.
Full-ACID
+ * leaves are named {@code bucket_NNNNN} (see {@link
AcidUtils#BUCKET_PATTERN})
+ * — the per-writeId delta directory is what keeps concurrent writers apart.
+ */
+ @Test
+ void testInsertIntoFullAcidOnFakeS3LandsUnderDeltaSubdir() throws Exception {
+ String tbl = "full_acid_fakes3";
+ Path loc = fakeS3Path(tbl);
+ runQuery(
+ "create table " + tbl + " (a int, b int) stored as orc "
+ + "location '" + fakeS3Location(tbl) + "' "
+ + "tblproperties ('transactional'='true')");
+
+ runQuery("insert into " + tbl + " values (1, 10), (2, 20), (3, 30)");
+
+ List<String> files = listFilesRelative(loc);
+ assertFalse(files.isEmpty(), "full-ACID insert produced no files under " +
loc);
+
+ // Every leaf must live under a delta_* directory and be a bucket_NNNNN
file.
+ // No _copy_ tag: full-ACID takes the isTransactional short-circuit in
+ // Hive.loadTable, bypassing pickDestFilePath entirely.
+ for (String rel : files) {
+ assertTrue(rel.contains("/" + AcidUtils.DELTA_PREFIX),
+ "full-ACID leaf must live under a delta_* subdir: " + rel);
+ String name = rel.substring(rel.lastIndexOf('/') + 1);
+ assertTrue(AcidUtils.BUCKET_PATTERN.matcher(name).matches(),
+ "full-ACID leaf must be a bucket_NNNNN name: " + rel);
+ }
+
+ assertRowCount(tbl, 3);
+ }
+
+ private void assertRowCount(String table, int count) throws Exception {
+ List<String> rows = runQuery("select count(*) from " + table);
+ assertEquals(String.valueOf(count), rows.getFirst(), "expected " + count +
" rows total");
+ }
+
+ private void convertToFullAcidAndAssertRowCount(String tbl, int
expectedRowCount) throws Exception {
+ runQuery("alter table " + tbl + " set tblproperties ('EXTERNAL'='FALSE')");
+ runQuery("alter table " + tbl + " set tblproperties
('transactional_properties'='default')");
+ runQuery("alter table " + tbl + " set tblproperties
('transactional'='true')");
+
+ assertRowCount(tbl, expectedRowCount);
+ }
+
+ /**
+ * Asserts that {@code name} is a copy-suffixed filename whose suffix is the
+ * 16-hex per-query uniqueness tag (as opposed to the numeric {@code _copy_N}
+ * fallback that {@link AcidUtils#ORIGINAL_PATTERN_COPY} also accepts).
Parses
+ * via {@link ParsedOutputFileName} so we track whatever it accepts.
+ */
+ private static void assertHas16HexUniquenessTag(String name) {
+ // Baseline: must match the copy pattern the readers accept.
+ assertTrue(AcidUtils.ORIGINAL_PATTERN_COPY.matcher(name).matches(),
+ "must match _copy_ pattern: " + name);
+ // Tighten: the suffix must be exactly 16 hex chars, not the numeric
fallback.
+ ParsedOutputFileName parsed = ParsedOutputFileName.parse(name);
+ assertTrue(parsed.matches(), "ParsedOutputFileName should recognize " +
name);
+ assertTrue(parsed.isCopyFile(), "expected copy suffix on " + name);
+ String tag = parsed.getCopyIndex();
+ assertEquals(16, tag.length(),
+ "copy suffix must be 16 chars (uniqueness tag, not numeric fallback),
got: " + tag + " (" + name + ")");
+ assertTrue(tag.matches("[0-9a-fA-F]{16}"),
+ "copy suffix must be lowercase-or-uppercase hex, got: " + tag + " (" +
name + ")");
+ }
+}
diff --git a/ql/src/test/org/apache/hadoop/hive/ql/util/FakeS3FileSystem.java
b/ql/src/test/org/apache/hadoop/hive/ql/util/FakeS3FileSystem.java
new file mode 100644
index 00000000000..22a1b90dc46
--- /dev/null
+++ b/ql/src/test/org/apache/hadoop/hive/ql/util/FakeS3FileSystem.java
@@ -0,0 +1,101 @@
+/*
+ * 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.hadoop.hive.ql.util;
+
+import java.io.IOException;
+import java.net.URI;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RawLocalFileSystem;
+import org.apache.hadoop.fs.permission.FsPermission;
+
+/**
+ * Test-only {@link RawLocalFileSystem} wrapper that advertises itself under
the
+ * synthetic {@value #SCHEME} scheme. Files still live on the local disk
(backed
+ * by {@link RawLocalFileSystem}), so tests can inspect them with plain
+ * {@link java.io.File} calls, but every {@link Path} that Hive resolves via
+ * {@link FileSystem#getFileSystem(URI, Configuration)} sees {@code
fakes3://…}.
+ *
+ * <p>Tests using this class typically add {@value #SCHEME} to
+ * {@code org.apache.hadoop.hive.common.FileUtils.NON_ATOMIC_RENAME_SCHEMES} so
+ * {@code FileUtils.isNonAtomicRenameFs} treats it like S3A and drives the
+ * non-atomic-rename branch of the move logic. Remove it again in an
+ * {@code @AfterClass}/{@code @AfterAll} hook so no other test sees the
mutation.
+ *
+ * <p>Register with:
+ * <pre>
+ * conf.setClass("fs." + FakeS3FileSystem.SCHEME + ".impl",
+ * FakeS3FileSystem.class, FileSystem.class);
+ * conf.setBoolean("fs." + FakeS3FileSystem.SCHEME + ".impl.disable.cache",
true);
+ * </pre>
+ * Disabling the FS cache is important — otherwise a per-test {@code
TemporaryFolder}
+ * root will leak between tests through the cached FS instance.
+ */
+public final class FakeS3FileSystem extends RawLocalFileSystem {
+
+ /** URI scheme this FS advertises. */
+ public static final String SCHEME = "fakes3";
+
+ private URI uri;
+
+ @Override
+ public void initialize(URI name, Configuration conf) throws IOException {
+ super.initialize(name, conf);
+ String authority = name.getAuthority() == null ? "" : name.getAuthority();
+ this.uri = URI.create(SCHEME + "://" + authority + "/");
+ }
+
+ @Override
+ public String getScheme() {
+ return SCHEME;
+ }
+
+ @Override
+ public URI getUri() {
+ return uri != null ? uri : URI.create(SCHEME + ":///");
+ }
+
+ // RawLocalFileSystem's DeprecatedRawLocalFileStatus lazy-loads permissions
via
+ // new File(getPath().toUri())
+ // and File(URI) requires scheme=="file", so it throws on every
getPermission()
+ // call for our fakes3:// URIs. Replace the returned statuses with plain
+ // FileStatus objects whose permission field is populated at construction
time,
+ // so getPermission() is a simple field read that never hits the broken
loader.
+ private static FileStatus withPermission(FileStatus s) throws IOException {
+ return new FileStatus(s.getLen(), s.isDirectory(), s.getReplication(),
s.getBlockSize(),
+ s.getModificationTime(), s.getAccessTime(), new FsPermission((short)
0644),
+ "hive", "hive", s.isSymlink() ? s.getSymlink() : null, s.getPath());
+ }
+
+ @Override
+ public FileStatus getFileStatus(Path f) throws IOException {
+ return withPermission(super.getFileStatus(f));
+ }
+
+ @Override
+ public FileStatus[] listStatus(Path f) throws IOException {
+ FileStatus[] arr = super.listStatus(f);
+ for (int i = 0; i < arr.length; i++) {
+ arr[i] = withPermission(arr[i]);
+ }
+ return arr;
+ }
+}
diff --git a/ql/src/test/queries/clientpositive/acid_convert_16hex_copy_tag.q
b/ql/src/test/queries/clientpositive/acid_convert_16hex_copy_tag.q
new file mode 100644
index 00000000000..e9325c6f9a9
--- /dev/null
+++ b/ql/src/test/queries/clientpositive/acid_convert_16hex_copy_tag.q
@@ -0,0 +1,53 @@
+-- Convert a non-ACID managed ORC table to full ACID after renaming the
+-- inserted file to the 16-hex per-query "uniqueness tag" copy suffix that
+-- form introduced for unstable-rename filesystems (S3A/S3N/S3/GS).
+-- Before that change, the ORIGINAL_PATTERN_COPY regex in
+-- TransactionalValidationListener only matched `_copy_[0-9]+`, so the
+-- pre-existing file would be flagged as an "unexpected data file name
+-- format" and the ALTER TABLE ... transactional=true would fail. This
+-- test locks in the widened pattern.
+
+set hive.create.as.acid=false;
+set hive.create.as.insert.only=false;
+set hive.strict.managed.tables=false;
+
+set hive.support.concurrency=true;
+set hive.txn.manager=org.apache.hadoop.hive.ql.lockmgr.DbTxnManager;
+set hive.mapred.mode=nonstrict;
+
+drop table if exists t_acid_convert_16hex;
+
+create table t_acid_convert_16hex (id int, name string)
+ stored as orc
+ tblproperties ('transactional'='false');
+
+insert into t_acid_convert_16hex values (1, 'a'), (2, 'b');
+
+-- What the insert produced on a stable-rename FS (local test): expect a
+-- single `000000_0` (or similar numeric) file.
+dfs -ls ${hiveconf:hive.metastore.warehouse.dir}/t_acid_convert_16hex;
+
+-- Rename it to the 16-hex form that Hive.mvFile would have chosen on e.g. S3A
+-- This is exactly the shape the widened
TransactionalValidationListener.ORIGINAL_PATTERN_COPY has to accept.
+dfs -mv ${hiveconf:hive.metastore.warehouse.dir}/t_acid_convert_16hex/000000_0
+
${hiveconf:hive.metastore.warehouse.dir}/t_acid_convert_16hex/000000_0_copy_f0796c02aef8435d;
+
+dfs -ls ${hiveconf:hive.metastore.warehouse.dir}/t_acid_convert_16hex;
+
+-- The conversion. This is what would blow up with
+-- IllegalStateException: Unexpected data file name format.
+-- Cannot convert default.t_acid_convert_16hex to transactional table.
+-- if ORIGINAL_PATTERN_COPY still required a numeric copy index.
+alter table t_acid_convert_16hex set tblproperties ('transactional'='true',
'transactional_properties'='default');
+
+describe formatted t_acid_convert_16hex;
+
+-- Original rows still visible after conversion.
+select id, name from t_acid_convert_16hex order by id;
+
+-- Sanity: ACID-only ops now work end-to-end.
+update t_acid_convert_16hex set name = 'B' where id = 2;
+delete from t_acid_convert_16hex where id = 1;
+select id, name from t_acid_convert_16hex order by id;
+
+drop table t_acid_convert_16hex;
diff --git
a/ql/src/test/results/clientpositive/llap/acid_convert_16hex_copy_tag.q.out
b/ql/src/test/results/clientpositive/llap/acid_convert_16hex_copy_tag.q.out
new file mode 100644
index 00000000000..59f3732f19d
--- /dev/null
+++ b/ql/src/test/results/clientpositive/llap/acid_convert_16hex_copy_tag.q.out
@@ -0,0 +1,124 @@
+PREHOOK: query: drop table if exists t_acid_convert_16hex
+PREHOOK: type: DROPTABLE
+PREHOOK: Output: database:default
+POSTHOOK: query: drop table if exists t_acid_convert_16hex
+POSTHOOK: type: DROPTABLE
+POSTHOOK: Output: database:default
+PREHOOK: query: create table t_acid_convert_16hex (id int, name string)
+ stored as orc
+ tblproperties ('transactional'='false')
+PREHOOK: type: CREATETABLE
+PREHOOK: Output: database:default
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: create table t_acid_convert_16hex (id int, name string)
+ stored as orc
+ tblproperties ('transactional'='false')
+POSTHOOK: type: CREATETABLE
+POSTHOOK: Output: database:default
+POSTHOOK: Output: default@t_acid_convert_16hex
+PREHOOK: query: insert into t_acid_convert_16hex values (1, 'a'), (2, 'b')
+PREHOOK: type: QUERY
+PREHOOK: Input: _dummy_database@_dummy_table
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: insert into t_acid_convert_16hex values (1, 'a'), (2, 'b')
+POSTHOOK: type: QUERY
+POSTHOOK: Input: _dummy_database@_dummy_table
+POSTHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: Lineage: t_acid_convert_16hex.id SCRIPT []
+POSTHOOK: Lineage: t_acid_convert_16hex.name SCRIPT []
+Found 1 items
+#### A masked pattern was here ####
+Found 1 items
+#### A masked pattern was here ####
+PREHOOK: query: alter table t_acid_convert_16hex set tblproperties
('transactional'='true', 'transactional_properties'='default')
+PREHOOK: type: ALTERTABLE_PROPERTIES
+PREHOOK: Input: default@t_acid_convert_16hex
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: alter table t_acid_convert_16hex set tblproperties
('transactional'='true', 'transactional_properties'='default')
+POSTHOOK: type: ALTERTABLE_PROPERTIES
+POSTHOOK: Input: default@t_acid_convert_16hex
+POSTHOOK: Output: default@t_acid_convert_16hex
+PREHOOK: query: describe formatted t_acid_convert_16hex
+PREHOOK: type: DESCTABLE
+PREHOOK: Input: default@t_acid_convert_16hex
+POSTHOOK: query: describe formatted t_acid_convert_16hex
+POSTHOOK: type: DESCTABLE
+POSTHOOK: Input: default@t_acid_convert_16hex
+# col_name data_type comment
+id int
+name string
+
+# Detailed Table Information
+Database: default
+#### A masked pattern was here ####
+Retention: 0
+#### A masked pattern was here ####
+Table Type: MANAGED_TABLE
+Table Parameters:
+ bucketing_version 2
+#### A masked pattern was here ####
+ numFiles 1
+ numRows 2
+ rawDataSize 178
+ totalSize #Masked#
+ transactional true
+ transactional_properties default
+#### A masked pattern was here ####
+
+# Storage Information
+SerDe Library: org.apache.hadoop.hive.ql.io.orc.OrcSerde
+InputFormat: org.apache.hadoop.hive.ql.io.orc.OrcInputFormat
+OutputFormat: org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat
+Compressed: No
+Num Buckets: -1
+Bucket Columns: []
+Sort Columns: []
+PREHOOK: query: select id, name from t_acid_convert_16hex order by id
+PREHOOK: type: QUERY
+PREHOOK: Input: default@t_acid_convert_16hex
+#### A masked pattern was here ####
+POSTHOOK: query: select id, name from t_acid_convert_16hex order by id
+POSTHOOK: type: QUERY
+POSTHOOK: Input: default@t_acid_convert_16hex
+#### A masked pattern was here ####
+1 a
+2 b
+PREHOOK: query: update t_acid_convert_16hex set name = 'B' where id = 2
+PREHOOK: type: QUERY
+PREHOOK: Input: default@t_acid_convert_16hex
+PREHOOK: Output: default@t_acid_convert_16hex
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: update t_acid_convert_16hex set name = 'B' where id = 2
+POSTHOOK: type: QUERY
+POSTHOOK: Input: default@t_acid_convert_16hex
+POSTHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: Lineage: t_acid_convert_16hex.id SIMPLE []
+POSTHOOK: Lineage: t_acid_convert_16hex.name SIMPLE []
+PREHOOK: query: delete from t_acid_convert_16hex where id = 1
+PREHOOK: type: QUERY
+PREHOOK: Input: default@t_acid_convert_16hex
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: delete from t_acid_convert_16hex where id = 1
+POSTHOOK: type: QUERY
+POSTHOOK: Input: default@t_acid_convert_16hex
+POSTHOOK: Output: default@t_acid_convert_16hex
+PREHOOK: query: select id, name from t_acid_convert_16hex order by id
+PREHOOK: type: QUERY
+PREHOOK: Input: default@t_acid_convert_16hex
+#### A masked pattern was here ####
+POSTHOOK: query: select id, name from t_acid_convert_16hex order by id
+POSTHOOK: type: QUERY
+POSTHOOK: Input: default@t_acid_convert_16hex
+#### A masked pattern was here ####
+2 B
+PREHOOK: query: drop table t_acid_convert_16hex
+PREHOOK: type: DROPTABLE
+PREHOOK: Input: default@t_acid_convert_16hex
+PREHOOK: Output: database:default
+PREHOOK: Output: default@t_acid_convert_16hex
+POSTHOOK: query: drop table t_acid_convert_16hex
+POSTHOOK: type: DROPTABLE
+POSTHOOK: Input: default@t_acid_convert_16hex
+POSTHOOK: Output: database:default
+POSTHOOK: Output: default@t_acid_convert_16hex
diff --git
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/TransactionalValidationListener.java
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/TransactionalValidationListener.java
index 37a10625ce5..44532570f53 100644
---
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/TransactionalValidationListener.java
+++
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/TransactionalValidationListener.java
@@ -448,8 +448,10 @@ private String validateTransactionalProperties(String
transactionalProperties) {
/**
* see org.apache.hadoop.hive.ql.exec.Utilities#COPY_KEYWORD
*/
+ // Copy suffix is either a numeric counter (HDFS/local: _copy_N) or a 16-hex
per-query
+ // uniqueness tag (non-atomic-rename FS such as S3A: _copy_<queryTag>).
private static final Pattern ORIGINAL_PATTERN_COPY =
- Pattern.compile("[0-9]+_[0-9]+" + "_copy_" + "[0-9]+");
+ Pattern.compile("[0-9]+_[0-9]+" + "_copy_" +
"(?:[0-9]{1,6}|[0-9a-fA-F]{16})");
/**
* It's assumed everywhere that original data files are named according to