This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new f8ba3c2428c [branch-4.0][fix](auto-partition) avoid NPE in 
createPartition when a partition is concurrently dropped (#65357)
f8ba3c2428c is described below

commit f8ba3c2428c2d58bf49191659378f0b677ac6d4e
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Jul 8 20:21:08 2026 +0800

    [branch-4.0][fix](auto-partition) avoid NPE in createPartition when a 
partition is concurrently dropped (#65357)
    
    ## Proposed changes
    
    Fix two flaky failures found in a branch-4.0 P0 regression run (both in
    the backup/restore area).
    Root causes were confirmed from the FE/BE logs of that run.
    
    ### 1. `test_backup_restore_retention_count` — NPE in `createPartition`
    under concurrent partition drop
    
    An auto-partition INSERT creates partitions on the fly via the
    `createPartition` RPC.
    `FrontendServiceImpl.createPartition()` adds the partitions and then
    reads them back to build the
    tablet-location response, **without null-checking**
    `table.getPartition(partitionName)`. When
    `DynamicPartitionScheduler` concurrently drops a just-created history
    partition (enforcing
    `partition.retention_count`), the read-back gets `null` and
    `partition.getId()` throws NPE:
    
    ```
    Caused by: java.lang.NullPointerException: Cannot invoke
      "org.apache.doris.catalog.Partition.getId()" because "partition" is null
        at 
org.apache.doris.service.FrontendServiceImpl.createPartition(FrontendServiceImpl.java:3885)
    ```
    
    The unchecked exception escapes to the Thrift layer as `Internal error
    processing createPartition`,
    failing the load with `THRIFT_RPC_ERROR`. This can affect any
    auto-partition table with
    dynamic-partition / `retention_count` cleanup, not only the test.
    
    - FE: null-check the read-back and return a retryable error status
    instead of throwing NPE.
    - Test: retry the INSERT on the transient race (surfaced when a
    concurrent suite lowers the global
      `dynamic_partition_check_interval_seconds`).
    
    ### 2. `test_ddl_backup_auth` — global backup lock contention
    
    BACKUP submission serializes on a single global `BackupHandler` lock via
    `tryLock(10s)`. Under
    parallel P0 with many concurrent backup/restore suites, that lock can be
    held past 10s by slow S3
    operations, so submission transiently fails with `Another backup or
    restore job is being submitted`.
    The suite only verifies auth, so it now retries the submit on that
    contention.
---
 .../insert/streaming/StreamingInsertJob.java       | 43 ++++++++++++++++++----
 .../apache/doris/service/FrontendServiceImpl.java  | 12 +++++-
 .../suites/auth_call/test_ddl_backup_auth.groovy   | 24 ++++++++++--
 .../test_backup_restore_retention_count.groovy     | 29 ++++++++++++---
 4 files changed, 90 insertions(+), 18 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index 91f193d098d..72c6e1725e2 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -416,6 +416,27 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         this.setJobRuntimeMsg("");
     }
 
+    /**
+     * Clear the failure info after a task succeeds, but only if the job has 
not been paused.
+     * A streaming job runs the data task and the meta-fetch scheduler task 
concurrently on
+     * different thread pools. If a fetchMeta failure has paused (or is 
pausing) the job, a
+     * straggler successful task must not wipe out the pause reason via 
resetFailureInfo(null):
+     * doing so hides the cause (empty ErrorMsg) and, because auto resume only 
fires when
+     * failureReason != null, leaves the job stuck in PAUSED forever. Guarding 
under the write
+     * lock (which the fetchMeta pause path also takes) makes the two paths 
mutually exclusive
+     * so the pause reason always survives.
+     */
+    private void clearFailureInfoIfNotPaused() {
+        writeLock();
+        try {
+            if (!JobStatus.PAUSED.equals(getJobStatus())) {
+                resetFailureInfo(null);
+            }
+        } finally {
+            writeUnlock();
+        }
+    }
+
     @Override
     public void cancelAllTasks(boolean needWaitCancelComplete) throws 
JobException {
         lock.writeLock().lock();
@@ -543,12 +564,20 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
                     || 
!InternalErrorCode.MANUAL_PAUSE_ERR.equals(this.getFailureReason().getCode())) {
                 // When a job is manually paused, it does not need to be set 
again,
                 // otherwise, it may be woken up by auto resume.
-                this.setFailureReason(
-                        new 
FailureReason(InternalErrorCode.GET_REMOTE_DATA_ERROR,
-                                "Failed to fetch meta, " + ex.getMessage()));
-                // If fetching meta fails, the job is paused
-                // and auto resume will automatically wake it up.
-                this.updateJobStatus(JobStatus.PAUSED);
+                // Set the failure reason and pause atomically under the write 
lock so a
+                // concurrent successful task (onStreamTaskSuccess) cannot 
clear the reason
+                // between these two writes. See clearFailureInfoIfNotPaused().
+                writeLock();
+                try {
+                    this.setFailureReason(
+                            new 
FailureReason(InternalErrorCode.GET_REMOTE_DATA_ERROR,
+                                    "Failed to fetch meta, " + 
ex.getMessage()));
+                    // If fetching meta fails, the job is paused
+                    // and auto resume will automatically wake it up.
+                    this.updateJobStatus(JobStatus.PAUSED);
+                } finally {
+                    writeUnlock();
+                }
 
                 if (MetricRepo.isInit) {
                     
MetricRepo.COUNTER_STREAMING_JOB_GET_META_FAIL_COUNT.increase(1L);
@@ -617,7 +646,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
 
     public void onStreamTaskSuccess(AbstractStreamingTask task) {
         try {
-            resetFailureInfo(null);
+            clearFailureInfoIfNotPaused();
             succeedTaskCount.incrementAndGet();
             //update metric
             if (MetricRepo.isInit) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index f39367b0e45..12390e1952e 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -3877,8 +3877,18 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
         String cachedClusterId = null;
         for (String partitionName : addPartitionClauseMap.keySet()) {
             Partition partition = table.getPartition(partitionName);
+            if (partition == null) {
+                // The partition was just created above, but may have been 
concurrently dropped before this
+                // read-back, e.g. by DynamicPartitionScheduler enforcing 
partition.retention_count or dynamic
+                // partition cleanup. Return a retryable error status instead 
of throwing NPE on partition.getId().
+                errorStatus.setErrorMsgs(Lists.newArrayList(String.format(
+                        "partition %s was concurrently dropped, please retry", 
partitionName)));
+                result.setStatus(errorStatus);
+                LOG.warn("send create partition error status: {}", result);
+                return result;
+            }
             // For thread safety, we preserve the tablet distribution 
information of each partition
-            // before calling getOrSetAutoPartitionInfo, but not check the 
partition first
+            // before calling getOrSetAutoPartitionInfo.
             List<TTabletLocation> partitionTablets = new ArrayList<>();
             List<TTabletLocation> partitionSlaveTablets = new ArrayList<>();
             TOlapTablePartition tPartition = new TOlapTablePartition();
diff --git a/regression-test/suites/auth_call/test_ddl_backup_auth.groovy 
b/regression-test/suites/auth_call/test_ddl_backup_auth.groovy
index 1a2f7b79718..4c00d5bae18 100644
--- a/regression-test/suites/auth_call/test_ddl_backup_auth.groovy
+++ b/regression-test/suites/auth_call/test_ddl_backup_auth.groovy
@@ -109,10 +109,26 @@ suite("test_ddl_backup_auth","p0,auth_call") {
     assertTrue(res.size() == 0)
 
     connect(user, "${pwd}", context.config.jdbcUrl) {
-        sql """BACKUP SNAPSHOT ${dbName}.${backupLabelName}
-                TO ${repositoryName}
-                ON (${tableName})
-                PROPERTIES ("type" = "full");"""
+        // BACKUP submission serializes on a single global BackupHandler lock 
(tryLock(10s)). Under parallel
+        // P0 with many concurrent backup/restore suites, that lock can be 
held past 10s by slow S3 ops,
+        // so submission may transiently fail with "Another backup or restore 
job is being submitted".
+        // This suite only verifies auth (a user with LOAD_PRIV can submit 
backup), so retry the contention.
+        def maxBackupRetries = 15
+        for (int i = 0; i < maxBackupRetries; i++) {
+            try {
+                sql """BACKUP SNAPSHOT ${dbName}.${backupLabelName}
+                        TO ${repositoryName}
+                        ON (${tableName})
+                        PROPERTIES ("type" = "full");"""
+                break
+            } catch (Exception e) {
+                if (i == maxBackupRetries - 1 || 
!e.getMessage().contains("Another backup or restore job")) {
+                    throw e
+                }
+                logger.warn("backup submit contended on global lock, retry ${i 
+ 1}: ${e.getMessage()}")
+                sleep(2000)
+            }
+        }
         res = sql """SHOW BACKUP FROM ${dbName};"""
         logger.info("res: " + res)
         assertTrue(res.size() == 1)
diff --git 
a/regression-test/suites/backup_restore/test_backup_restore_retention_count.groovy
 
b/regression-test/suites/backup_restore/test_backup_restore_retention_count.groovy
index 96fe217eac8..45914e61aaa 100644
--- 
a/regression-test/suites/backup_restore/test_backup_restore_retention_count.groovy
+++ 
b/regression-test/suites/backup_restore/test_backup_restore_retention_count.groovy
@@ -39,12 +39,29 @@ suite("test_backup_restore_retention_count", 
"backup_restore") {
         )
     """
 
-    // Insert data to create partitions
-    sql """
-        INSERT INTO ${dbName}.${tableName} 
-        SELECT date_add('2020-01-01 00:00:00', interval number day) 
-        FROM numbers("number" = "10")
-    """
+    // Insert data to create partitions.
+    // Auto-partition on-the-fly createPartition can race with 
DynamicPartitionScheduler's retention
+    // cleanup (partition.retention_count) when a concurrent suite lowers the 
global
+    // dynamic_partition_check_interval_seconds: the scheduler may drop a 
just-created history partition
+    // before the createPartition RPC finishes, surfacing as a transient 
createPartition RPC error.
+    // Retry the insert on that transient contention.
+    def maxInsertRetries = 10
+    for (int i = 0; i < maxInsertRetries; i++) {
+        try {
+            sql """
+                INSERT INTO ${dbName}.${tableName}
+                SELECT date_add('2020-01-01 00:00:00', interval number day)
+                FROM numbers("number" = "10")
+            """
+            break
+        } catch (Exception e) {
+            if (i == maxInsertRetries - 1 || 
!e.getMessage().contains("createPartition")) {
+                throw e
+            }
+            logger.warn("insert raced with retention cleanup, retry ${i + 1}: 
${e.getMessage()}")
+            sleep(2000)
+        }
+    }
 
     sql "alter table ${dbName}.${tableName}  set ('partition.retention_count' 
= '4')"
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to