-
Notifications
You must be signed in to change notification settings - Fork 4.8k
HIVE-28822: Concurrent INSERTs into a new dynamic partition can silently lose rows or fail with FileAlreadyExistsException on S3 (non-ACID) #6642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5170,6 +5170,96 @@ private static String getPathName(int taskId) { | |
| return Utilities.replaceTaskId("000000", taskId) + "_0"; | ||
| } | ||
|
|
||
| /** | ||
| * Compute a compact per-query uniqueness tag (8 lowercase hex chars) used by the non-ACID | ||
| * rename branch of {@link #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. | ||
| * Reads {@code hive.query.id} from the passed {@link HiveConf}; returns the empty string | ||
| * when the id is missing. The 8 hex chars come from {@code queryId.hashCode()}; that is | ||
| * short enough to keep S3 listings readable and collision-free for realistic per-partition | ||
| * concurrency (birthday-collides only at ~65k concurrent writers to the same partition). | ||
| * <p> | ||
| * 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 (qid == null || qid.isEmpty()) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ack, fixed in aab3c3b |
||
| return ""; | ||
| } | ||
| return String.format("%08x", qid.hashCode()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HIVE_QUERY_ID =
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed aab3c3b
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe move to QueryPlan next to makeQueryId |
||
| } | ||
|
|
||
| /** | ||
| * 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, 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are you sure it also covers Insert-only (MM) transactional tables? otherwise we might fail in AcidUtils |
||
| 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 = | ||
| (taskId == -1 && isRenameAllowed && !isOverwrite && UnstableRenameFileSystem.matches(destFs)) | ||
| ? computeUniquenessTag(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 | ||
|
|
@@ -5199,37 +5289,7 @@ 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, sourcePath, destFs, destDirPath, taskId, isOverwrite, isRenameAllowed); | ||
|
|
||
| if (isRenameAllowed) { | ||
| destFs.rename(sourcePath, destFilePath); | ||
|
|
@@ -5241,18 +5301,18 @@ 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."); | ||
| } | ||
|
|
||
| // Source file delete may fail because of permission issue as executing user might not | ||
| // 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /* | ||
| * 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.util.EnumSet; | ||
| import java.util.Locale; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import org.apache.hadoop.fs.FileSystem; | ||
|
|
||
| /** | ||
| * File systems whose {@link FileSystem#rename(org.apache.hadoop.fs.Path, org.apache.hadoop.fs.Path)} | ||
| * implementation is not atomic-if-absent and can silently overwrite an existing destination when | ||
| * two concurrent writers race between an {@code exists()} probe and the subsequent rename call. | ||
| * <p> | ||
| * Object stores (S3, GCS, etc.) fall in this category: the S3A rename is a copy+delete on the | ||
| * client, with the "does the destination exist?" check performed on the client before the copy; | ||
| * two writers whose probes both fire before either PUT commits will both proceed and one will | ||
| * silently overwrite the other. | ||
| * <p> | ||
| * Callers use this enum to decide whether to apply defensive strategies such as suffixing the | ||
| * destination filename with a per-query tag so concurrent writers pick distinct keys — see | ||
| * {@code Hive#mvFile}. It is intentionally an in-code enum rather than a configuration knob: | ||
| * the set of unsafe filesystems is a property of the filesystem implementation, not something an | ||
| * operator should override. | ||
| */ | ||
| public enum UnstableRenameFileSystem { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Anything named *FileSystem in this codebase is a FileSystem subclass. maybe introduce instead allowlist - the known-safe (hdfs, file, viewfs) ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ack, and this is over-engineered too, fixed in aab3c3b |
||
| S3A("s3a"), | ||
| S3N("s3n"), | ||
| S3("s3"), | ||
| // Google Cloud Storage exposes the same "rename is copy+delete" semantics through the Hadoop | ||
| // connector; keep here so multi-cloud deployments are covered without further edits. | ||
| GS("gs"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did you consider abfs? please check BlobStorageUtils
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cannot make sure, depends on whether hierarchical namespaces are enabled
https://hadoop.apache.org/docs/stable/hadoop-azure/abfs.html#Rename_Options given the new implementation has no performance implications, I'm simply applying this for |
||
|
|
||
| private final String scheme; | ||
|
|
||
| UnstableRenameFileSystem(String scheme) { | ||
| this.scheme = scheme; | ||
| } | ||
|
|
||
| public String scheme() { | ||
| return scheme; | ||
| } | ||
|
|
||
| private static final Set<String> SCHEMES = EnumSet.allOf(UnstableRenameFileSystem.class).stream() | ||
| .map(UnstableRenameFileSystem::scheme).collect(Collectors.toSet()); | ||
|
|
||
| /** | ||
| * @return {@code true} when {@code scheme} matches one of the known unstable-rename | ||
| * filesystems; {@code false} otherwise (including {@code null} / empty). | ||
| */ | ||
| public static boolean matches(String scheme) { | ||
| return scheme != null && SCHEMES.contains(scheme.toLowerCase(Locale.ROOT)); | ||
| } | ||
|
|
||
| /** | ||
| * Convenience overload that inspects a {@link FileSystem}'s URI scheme. | ||
| * | ||
| * @return {@code true} when the filesystem's scheme matches one of the known unstable-rename | ||
| * filesystems. | ||
| */ | ||
| public static boolean matches(FileSystem fs) { | ||
| if (fs == null || fs.getUri() == null) { | ||
| return false; | ||
| } | ||
| return matches(fs.getUri().getScheme()); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.