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 6c47d3bc1693..1b20778e6ab5 100644 --- a/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java +++ b/ql/src/java/org/apache/hadoop/hive/ql/QueryPlan.java @@ -311,6 +311,18 @@ public static String makeQueryId() { + UUID.randomUUID().toString(); } + /** + * 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 713c7e56f848..45257d31f50d 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 @@ -31,8 +31,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 *
* All the components are here:
* tmp_(taskPrefix)00001_02_copy_1.zlib.gz
@@ -41,9 +44,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}))?" + // _
+ * 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 {@code mvFile} 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
+ * 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.
+ */
+ static String computeUniquenessTag(HiveConf conf) {
+ String qid = HiveConf.getVar(conf, 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);
+ }
+
+ /**
+ * @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).
+ */
+ 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());
+ }
+
+ /**
+ * 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.
+ *
+ * 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.
+ *
+ * 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()
+ && isNonAtomicRenameFs(destFs))
+ ? computeUniquenessTag(conf)
+ : null;
+
+ if (uniqueCopySuffix != null && !uniqueCopySuffix.isEmpty()) {
+ // Unstable-rename FS: use `name_copy_
* Moves a file from one {@link Path} to another. If {@code isRenameAllowed} is true then the
@@ -5199,37 +5322,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);
@@ -5241,7 +5335,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.");
}
@@ -5249,10 +5343,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 e09a5ecc3c33..ac222dc224b5 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
@@ -120,6 +120,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 2ef7bfcbccdd..e05b14bcd885 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
@@ -28,14 +28,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)
@@ -158,8 +163,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,
@@ -186,8 +191,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,
@@ -227,4 +232,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_ 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:
+ * 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 Hive.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.
+ Hive.NON_ATOMIC_RENAME_SCHEMES.add(FAKE_SCHEME);
+ SessionState.start(hiveConf);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ Hive.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 Hive#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", Hive.isNonAtomicRenameFs(fakeFs));
+
+ FileSystem localFs = new Path(tmp.getRoot().getAbsolutePath()).getFileSystem(hiveConf);
+ assertFalse("local FS must not be flagged", Hive.isNonAtomicRenameFs(localFs));
+ }
+
+ /**
+ * A single-file rename into a fresh destination under {@code fakes3://} must land at
+ * {@code 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 Hive#NON_ATOMIC_RENAME_SCHEMES}
+ * so {@link Hive#isNonAtomicRenameFs} treats it like S3A.
+ *
+ * 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() {
+ Hive.NON_ATOMIC_RENAME_SCHEMES.add(FAKE_SCHEME);
+ }
+
+ @AfterAll
+ static void removeFakeSchemeFromUnstableSet() {
+ Hive.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 Two concurrent MM inserts would get separate {@code delta_ Tests using this class typically add {@value #SCHEME} to
+ * {@code org.apache.hadoop.hive.ql.metadata.Hive.NON_ATOMIC_RENAME_SCHEMES} so
+ * {@code Hive.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.
+ *
+ * Register with:
+ *
+ *
+ */
+ @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", Hive.isNonAtomicRenameFs(localFs));
+ assertFalse("null fs is not flagged", Hive.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",
+ Hive.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",
+ Hive.isNonAtomicRenameFs(spy));
+ }
+
+ // (2) uniqueness tag: the 16-hex most-significant-bits half of the UUID at the tail of
+ // queryId (QueryPlan.makeQueryId → "
+ *
+ * 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
+ *
+ */
+ private void assertUnionSubdirLayoutAt(Path loc, String partitionSegment) throws IOException {
+ List
+ * conf.setClass("fs." + FakeS3FileSystem.SCHEME + ".impl",
+ * FakeS3FileSystem.class, FileSystem.class);
+ * conf.setBoolean("fs." + FakeS3FileSystem.SCHEME + ".impl.disable.cache", true);
+ *
+ * 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 000000000000..e9325c6f9a9b
--- /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 000000000000..59f3732f19d0
--- /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 16404a9ca9c8..11dc4b75f76e 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
@@ -447,8 +447,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_