This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 82fc98bddc [#11939] fix(core,auth): tolerate concurrent directory
creation instead of failing spuriously (#11940)
82fc98bddc is described below
commit 82fc98bddc70748d9166caff224ed52da3c61436
Author: Qi Yu <[email protected]>
AuthorDate: Wed Jul 8 20:33:36 2026 +0800
[#11939] fix(core,auth): tolerate concurrent directory creation instead of
failing spuriously (#11940)
### What changes were proposed in this pull request?
Add `DirectoryUtils.ensureDirectory(File)` in `common` (backed by
`Files.createDirectories`, which is atomic and idempotent) and replace
the racy `!dir.exists() && !dir.mkdirs()` / bare `!dir.mkdirs()`
patterns with it in:
- `KerberosAuthUtils.fetchKeytabFromUri` (`catalogs/hadoop-auth`)
- `JobManager` constructor and `JobManager.runJob` (`core`), keeping the
original exception types at both call sites
### Why are the changes needed?
`File.mkdirs()` returns `false` when the directory already exists, so
two threads racing to create the same directory (TOCTOU between
`exists()` and `mkdirs()`) make the loser fail spuriously even though
the directory was created. For `KerberosAuthUtils` this surfaces as
`IOException("Failed to create keytab directory .../keytabs")` → HTTP
500 when concurrent Kerberos-enabled Hive clients initialize, seen as
flaky `HudiCatalogKerberosHiveIT.testHudiCatalogWithKerberos`.
`JobManager.runJob` additionally fails misleadingly whenever the job
staging directory already exists.
Fix: #11939
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
New unit tests, each written first and verified to fail before the fix:
-
`TestKerberosAuthUtils.testConcurrentFetchKeytabCreatesParentDirectoryOnce`:
4 threads × 200 iterations racing `fetchKeytabFromUri` on a fresh parent
directory via `CyclicBarrier`. Reproduces the exact CI failure on the
first iteration before the fix; passes after.
- `TestJobManager.testRunJobSucceedsWhenStagingDirectoryAlreadyExists`:
with a fixed job ID, running the same job twice hits an existing staging
directory; threw `RuntimeException("Failed to create staging directory
...")` before the fix.
- `TestDirectoryUtils` (4 tests): nested creation, idempotency,
rejecting a regular file at the path, and barrier-synchronized
concurrent creation.
`./gradlew :common:test :catalogs:hadoop-auth:test -PskipITs` and
`TestJobManager` all pass.
---
.../catalog/hadoop/auth/KerberosAuthUtils.java | 6 +--
.../catalog/hadoop/auth/TestKerberosAuthUtils.java | 44 ++++++++++++++++++++++
.../java/org/apache/gravitino/job/JobManager.java | 14 +++++--
.../org/apache/gravitino/job/TestJobManager.java | 35 +++++++++++++++++
4 files changed, 92 insertions(+), 7 deletions(-)
diff --git
a/catalogs/hadoop-auth/src/main/java/org/apache/gravitino/catalog/hadoop/auth/KerberosAuthUtils.java
b/catalogs/hadoop-auth/src/main/java/org/apache/gravitino/catalog/hadoop/auth/KerberosAuthUtils.java
index c29b43a0d0..eefb05aa05 100644
---
a/catalogs/hadoop-auth/src/main/java/org/apache/gravitino/catalog/hadoop/auth/KerberosAuthUtils.java
+++
b/catalogs/hadoop-auth/src/main/java/org/apache/gravitino/catalog/hadoop/auth/KerberosAuthUtils.java
@@ -24,6 +24,7 @@ import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.nio.file.Files;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
@@ -104,9 +105,8 @@ public final class KerberosAuthUtils {
"HDFS URIs are not supported for keytab files");
File parentFile = keytabFile.getParentFile();
- if (parentFile != null && !parentFile.exists() && !parentFile.mkdirs()) {
- throw new IOException(
- String.format("Failed to create keytab directory %s",
parentFile.getAbsolutePath()));
+ if (parentFile != null) {
+ Files.createDirectories(parentFile.toPath());
}
FileFetcher.get().fetchFileFromUri(keytabUri, keytabFile, timeoutSec *
1000, hadoopConf);
diff --git
a/catalogs/hadoop-auth/src/test/java/org/apache/gravitino/catalog/hadoop/auth/TestKerberosAuthUtils.java
b/catalogs/hadoop-auth/src/test/java/org/apache/gravitino/catalog/hadoop/auth/TestKerberosAuthUtils.java
index a6759daa32..70cbc4cd22 100644
---
a/catalogs/hadoop-auth/src/test/java/org/apache/gravitino/catalog/hadoop/auth/TestKerberosAuthUtils.java
+++
b/catalogs/hadoop-auth/src/test/java/org/apache/gravitino/catalog/hadoop/auth/TestKerberosAuthUtils.java
@@ -21,6 +21,12 @@ package org.apache.gravitino.catalog.hadoop.auth;
import java.io.File;
import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.security.UserGroupInformation;
@@ -88,6 +94,44 @@ public class TestKerberosAuthUtils {
Assertions.assertTrue(exception.getMessage().contains("HDFS"));
}
+ @Test
+ public void testConcurrentFetchKeytabCreatesParentDirectoryOnce() throws
Exception {
+ File source = new File(tempDir, "source.keytab");
+ Files.writeString(source.toPath(), "keytab-content");
+ String sourceUri = source.toURI().toString();
+
+ int threads = 4;
+ int iterations = 200;
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ try {
+ for (int i = 0; i < iterations; i++) {
+ File parent = new File(tempDir, "race-" + i + "/keytabs");
+ CyclicBarrier barrier = new CyclicBarrier(threads);
+ List<Future<File>> futures = new ArrayList<>(threads);
+ for (int t = 0; t < threads; t++) {
+ File destination = new File(parent, "destination-" + t + ".keytab");
+ futures.add(
+ executor.submit(
+ () -> {
+ barrier.await();
+ return KerberosAuthUtils.fetchKeytabFromUri(
+ sourceUri,
+ destination,
+ 1,
+ false /* allowHdfsKeytabUri */,
+ null /* hadoopConf */);
+ }));
+ }
+ for (Future<File> future : futures) {
+ File fetched = future.get();
+ Assertions.assertTrue(fetched.exists());
+ }
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
@Test
public void testConfigureKrb5ConfSetsSystemProperty() {
String hadoopKrb5ConfKey = "gravitino.test.krb5.conf";
diff --git a/core/src/main/java/org/apache/gravitino/job/JobManager.java
b/core/src/main/java/org/apache/gravitino/job/JobManager.java
index 3e9ae66fdd..efa24d70ea 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -26,6 +26,7 @@ import com.google.common.base.Preconditions;
import java.io.File;
import java.io.IOException;
import java.net.URI;
+import java.nio.file.Files;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
@@ -126,9 +127,11 @@ public class JobManager implements JobOperationDispatcher {
String.format("Staging directory %s is not accessible",
stagingDirPath));
}
} else {
- if (!stagingDir.mkdirs()) {
+ try {
+ Files.createDirectories(stagingDir.toPath());
+ } catch (IOException e) {
throw new IllegalArgumentException(
- String.format("Failed to create staging directory %s",
stagingDirPath));
+ String.format("Failed to create staging directory %s",
stagingDirPath), e);
}
}
@@ -428,9 +431,12 @@ public class JobManager implements JobOperationDispatcher {
stagingDir.getAbsolutePath()
+ String.format(JOB_STAGING_DIR, metalake, jobTemplateName, jobId);
File jobStagingDir = new File(jobStagingPath);
- if (!jobStagingDir.mkdirs()) {
+ try {
+ Files.createDirectories(jobStagingDir.toPath());
+ } catch (IOException e) {
throw new RuntimeException(
- String.format("Failed to create staging directory %s for job %s",
jobStagingDir, jobId));
+ String.format("Failed to create staging directory %s for job %s",
jobStagingDir, jobId),
+ e);
}
// Create a JobTemplate by replacing the template parameters with the
jobConf values, and
diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
index 32348e789f..5edbbc18ef 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -516,6 +516,41 @@ public class TestJobManager {
() -> jobManager.runJob(metalake, "shell_job",
Collections.emptyMap()));
}
+ @Test
+ public void testRunJobSucceedsWhenStagingDirectoryAlreadyExists() throws
Exception {
+ mockedMetalake
+ .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+ .thenAnswer(a -> null);
+
+ JobTemplateEntity shellJobTemplate =
+ newShellJobTemplateEntity("shell_job", "A shell job template");
+ when(jobExecutor.submitJob(any())).thenReturn("job_execution_id_for_test");
+ doNothing().when(entityStore).put(any(JobEntity.class), anyBoolean());
+
+ // Use a fixed job ID so that both runs resolve to the same staging
directory.
+ IdGenerator fixedIdGenerator = Mockito.mock(IdGenerator.class);
+ when(fixedIdGenerator.nextId()).thenReturn(12345L);
+ JobManager fixedIdJobManager =
+ Mockito.spy(new JobManager(config, entityStore, fixedIdGenerator,
jobExecutor));
+ try {
+ // Stop the background schedulers to prevent interference with the test,
like setUp does.
+ fixedIdJobManager.cleanUpExecutor.shutdownNow();
+ fixedIdJobManager.statusPullExecutor.shutdownNow();
+ when(fixedIdJobManager.getJobTemplate(metalake, shellJobTemplate.name()))
+ .thenReturn(shellJobTemplate);
+
+ JobEntity first = fixedIdJobManager.runJob(metalake, "shell_job",
Collections.emptyMap());
+ Assertions.assertEquals(12345L, first.id());
+
+ // The staging directory for job 12345 exists now; running the job again
must not fail on
+ // directory creation.
+ JobEntity second = fixedIdJobManager.runJob(metalake, "shell_job",
Collections.emptyMap());
+ Assertions.assertEquals(12345L, second.id());
+ } finally {
+ fixedIdJobManager.close();
+ }
+ }
+
@Test
public void testCancelJob() throws IOException {
mockedMetalake