You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/08/12 04:46:03 UTC

(hugegraph-toolchain) branch master updated: refactor(hubble): correct query console, list rendering and load lifecycle defects (#755)

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

imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git


The following commit(s) were added to refs/heads/master by this push:
     new 83acb4d7b refactor(hubble): correct query console, list rendering and load lifecycle defects (#755)
83acb4d7b is described below

commit 83acb4d7bef9e5293eb18fe5abe50a75ccfbb685
Author: KAI <hi...@gmail.com>
AuthorDate: Wed Aug 12 10:15:58 2026 +0530

    refactor(hubble): correct query console, list rendering and load lifecycle defects (#755)
    
    Bug fixes for Hubble found during a focused exploration pass, followed by a code audit of the surrounding paths. 10 commits, 62 files.
    
    Findings were originally gathered against a single deployment shape: the all-in-one hugegraph/hugegraph:latest image, RocksDB backend, standalone mode with PD disabled. The fixes have since been re-validated against a distributed deployment (3 PD, 3 Store, 3 Server, PR-head Hubble).
    
    The standalone fixes are complete and the repository-native regression baseline remains green. The live distributed round found no distributed-specific production compatibility correction was needed; its first follow-up added a regression test for the negative page_size rejection. Two later bot-review commits narrowly harden concurrency, migration, stale-response, retention, and launcher process-identity edges in those same documented fixed paths.
    
    ---------
    
    Co-authored-by: dark <ji...@apache.org>
---
 .../org/apache/hugegraph/driver/CypherManager.java |  12 +-
 .../org/apache/hugegraph/driver/HugeClient.java    |  33 +-
 .../hugegraph/config/DatabaseSchemaMigrator.java   | 609 ++++++++++++++++++++-
 .../controller/graph/GraphController.java          |  54 +-
 .../controller/ingest/IngestController.java        |  22 +
 .../controller/load/FileMappingController.java     |  40 +-
 .../controller/load/FileUploadController.java      |  72 ++-
 .../controller/load/JobManagerController.java      |  10 +-
 .../controller/load/LoadTaskController.java        |  20 +-
 .../hugegraph/controller/op/HStoreController.java  |  48 +-
 .../hugegraph/controller/op/LogController.java     |  10 +-
 .../org/apache/hugegraph/entity/load/LoadTask.java |  14 +-
 .../hugegraph/entity/query/ApplicationInfo.java    |   4 +
 .../hugegraph/entity/query/ElementEditHistory.java |   2 +
 .../apache/hugegraph/handler/ExceptionAdvisor.java |   7 +-
 .../hugegraph/mapper/load/FileMappingMapper.java   |   7 +
 .../hugegraph/mapper/load/JobManagerMapper.java    |  14 +-
 .../mapper/query/EditElementHistoryMapper.java     |   8 +-
 .../mapper/query/ExecuteHistoryMapper.java         |  53 +-
 .../hugegraph/service/graphs/GraphsService.java    |  61 ++-
 .../hugegraph/service/load/DatasourceService.java  |   4 +-
 .../hugegraph/service/load/FileMappingService.java |  87 ++-
 .../hugegraph/service/load/JobManagerService.java  |  45 +-
 .../hugegraph/service/load/LoadTaskService.java    |  74 ++-
 .../org/apache/hugegraph/service/op/ESService.java |   7 +-
 .../apache/hugegraph/service/op/LogService.java    |   6 +-
 .../service/query/EditElementHistoryService.java   |   5 +-
 .../service/query/ExecuteHistoryService.java       |   7 +-
 .../service/query/GremlinCollectionService.java    |   2 +-
 .../hugegraph/service/query/QueryService.java      | 107 +++-
 .../org/apache/hugegraph/util/GremlinUtil.java     | 190 ++++++-
 .../java/org/apache/hugegraph/util/PageUtil.java   |  24 +
 .../src/main/resources/database/schema.sql         |   7 +-
 .../src/main/resources/i18n/messages.properties    |  25 +-
 .../main/resources/i18n/messages_zh_CN.properties  |  23 +
 .../hugegraph/config/LegacySchemaStartupTest.java  | 233 ++++++++
 .../controller/op/HStoreControllerTest.java        | 114 ++++
 .../load/IngestTransactionIntegrationTest.java     | 150 +++++
 .../service/query/ExecuteHistoryRetentionTest.java | 188 +++++++
 .../hugegraph/unit/FileMappingDeletionTest.java    | 110 ++++
 .../hugegraph/unit/FileMappingSchemaTest.java      | 276 ++++++++++
 .../hugegraph/unit/FileUploadControllerTest.java   | 147 +++++
 .../org/apache/hugegraph/unit/GremlinUtilTest.java | 190 +++++++
 .../hugegraph/unit/LoaderScopeControllerTest.java  |  11 +-
 .../org/apache/hugegraph/unit/PriorityFixTest.java |  20 +
 .../org/apache/hugegraph/unit/UnitTestSuite.java   |   1 +
 .../assembly/static/bin/start-hubble.sh            |  46 +-
 .../hubble-dist/assembly/static/bin/stop-hubble.sh |  70 ++-
 hugegraph-hubble/hubble-fe/src/api/request.js      |  17 +
 .../src/i18n/resources/en-US/modules/analysis.json |   1 +
 .../src/i18n/resources/en-US/modules/pages.json    |   2 +
 .../src/i18n/resources/zh-CN/modules/analysis.json |   1 +
 .../src/i18n/resources/zh-CN/modules/pages.json    |   2 +
 .../hubble-fe/src/modules/algorithm/Home/index.js  |  14 +
 .../hubble-fe/src/modules/analysis/Home/index.js   |  12 +-
 .../src/modules/asyncTasks/Detail/index.js         |   2 +-
 .../src/modules/component/DynamicAddEdge/index.js  |   2 +-
 .../src/modules/component/ImportData/index.js      |  25 +-
 .../hubble-fe/src/pages/Datasource/EditLayer.js    |  55 +-
 .../src/pages/Datasource/EditLayer.test.js         |  83 ++-
 .../hubble-fe/src/pages/Graph/index.js             |   5 +-
 .../hubble-fe/src/pages/Graph/index.module.scss    |   4 +
 .../hubble-fe/src/pages/GraphSpace/Card.js         |   2 +-
 .../hubble-fe/src/pages/Meta/Edge/index.js         |   5 +-
 .../hubble-fe/src/pages/Meta/Vertex/index.js       |   3 +-
 hugegraph-hubble/hubble-fe/src/pages/My/index.js   |   6 +-
 .../hubble-fe/src/pages/Operations/Nodes.js        |   4 +-
 .../src/pages/Task/components/EditLayer.js         |  44 +-
 hugegraph-hubble/hubble-fe/src/pages/Task/index.js |  13 +-
 .../hubble-fe/src/pages/TaskDetail/index.js        |   7 +-
 .../hubble-fe/src/pages/TaskEdit/BaseForm/index.js |  13 +-
 .../hubble-fe/src/utils/formatTimeDuration.js      |  13 +-
 72 files changed, 3361 insertions(+), 243 deletions(-)

diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/CypherManager.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/CypherManager.java
index 498e568a0..2cf9e483e 100644
--- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/CypherManager.java
+++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/CypherManager.java
@@ -18,6 +18,7 @@
 package org.apache.hugegraph.driver;
 
 import org.apache.hugegraph.api.gremlin.CypherAPI;
+import org.apache.hugegraph.exception.ServerException;
 import org.apache.hugegraph.api.job.CypherJobAPI;
 import org.apache.hugegraph.client.RestClient;
 import org.apache.hugegraph.structure.gremlin.Response;
@@ -47,8 +48,17 @@ public class CypherManager {
 
     public ResultSet execute(String cypher) {
         Response response = this.cypherAPI.post(this.graphSpace, this.graph, cypher);
+        Response.Status status = response.status();
+        if (status != null && status.code() != 200) {
+            String message = status.message();
+            if (message == null || message.isEmpty()) {
+                message = "Cypher query failed with status code " + status.code();
+            }
+            ServerException exception = new ServerException(message);
+            exception.status(status.code());
+            throw exception;
+        }
         response.graphManager(this.graphManager);
-        // TODO: Can add some checks later
         return response.result();
     }
 
diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java
index 6327b0023..3f26091f3 100644
--- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java
+++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java
@@ -50,6 +50,13 @@ public class HugeClient implements Closeable {
 
     private final boolean borrowedClient;
     private final RestClient client;
+    /*
+     * Whether the server api-version handshake has been performed for
+     * this client instance, so that it runs only once instead of on
+     * every assignGraph() call
+     */
+    private volatile boolean apiVersionChecked;
+    private final Object apiVersionLock = new Object();
     private VersionManager version;
     private GraphsManager graphs;
     private SchemaManager schema;
@@ -145,9 +152,18 @@ public class HugeClient implements Closeable {
     public void initManagers(RestClient client, String graphSpace,
                              String graph) {
         assert client != null;
-        // Check hugegraph-server api version
+        // Check hugegraph-server api version (once per client instance)
         this.version = new VersionManager(client);
-        this.checkServerApiVersion();
+        if (!this.apiVersionChecked) {
+            synchronized (this.apiVersionLock) {
+                if (!this.apiVersionChecked) {
+                    this.checkServerApiVersion();
+                    // Failed handshakes leave the flag false so a later
+                    // assignment can retry.
+                    this.apiVersionChecked = true;
+                }
+            }
+        }
 
         this.graphs = new GraphsManager(client, graphSpace);
         this.auth = new AuthManager(client, graphSpace, graph);
@@ -171,6 +187,19 @@ public class HugeClient implements Closeable {
             this.variable = new VariablesManager(client, graphSpace, graph);
             this.job = new JobManager(client, graphSpace, graph);
             this.task = new TaskManager(client, graphSpace, graph);
+        } else {
+            /*
+             * No graph is assigned: reset all graph-scoped managers so
+             * that stale managers from a previous graph cannot be used
+             */
+            this.schema = null;
+            this.graph = null;
+            this.gremlin = null;
+            this.cypher = null;
+            this.traverser = null;
+            this.variable = null;
+            this.job = null;
+            this.task = null;
         }
     }
 
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/DatabaseSchemaMigrator.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/DatabaseSchemaMigrator.java
index a95bad3fd..235c36bb7 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/DatabaseSchemaMigrator.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/DatabaseSchemaMigrator.java
@@ -24,13 +24,21 @@ import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
 
 import javax.sql.DataSource;
 
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.BeanInitializationException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.core.Ordered;
+import org.springframework.core.PriorityOrdered;
 import org.springframework.stereotype.Component;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -42,27 +50,75 @@ import lombok.extern.log4j.Log4j2;
 
 @Log4j2
 @Component
-public class DatabaseSchemaMigrator implements ApplicationRunner {
+public class DatabaseSchemaMigrator implements BeanPostProcessor,
+                                               PriorityOrdered {
 
     private static final int FILE_MAPPING_PATH_LENGTH = 2048;
+    private static final int COLLECTION_NAME_LENGTH = 48;
+    private static final String DEFAULT_GRAPHSPACE = "DEFAULT";
+    private static final String DEFAULT_GRAPH = "hugegraph";
+    private static final String GREMLIN_TYPE = "GREMLIN";
     private static final String FILE_MAPPING_TABLE = "file_mapping";
     private static final String FILE_MAPPING_PATH_COLUMN = "path";
     private static final String EXECUTE_HISTORY_TABLE = "execute_history";
     private static final String FAILURE_REASON_COLUMN = "failure_reason";
     private static final String LOAD_TASK_TABLE = "load_task";
+    private static final String GREMLIN_COLLECTION_TABLE =
+                                "gremlin_collection";
+    private static final String GREMLIN_COLLECTION_SCOPE_INDEX =
+                                "gremlin_collection_scope_name";
+    /*
+     * Columns added to `execute_history` after the legacy schema was
+     * released. Each entry is {column, type, backfill value or null}.
+     */
+    private static final String[][] EXECUTE_HISTORY_COLUMNS = {
+        {"conn_id", "INT", null},
+        {"graphspace", "VARCHAR(48)", null},
+        {"graph", "VARCHAR(48)", null},
+        {"async_id", "BIGINT", "0"},
+        {"text", "TEXT", "''"},
+        {"async_status", "TINYINT", "0"}
+    };
+    /* Columns added to `gremlin_collection` after the legacy schema. */
+    private static final String[][] GREMLIN_COLLECTION_COLUMNS = {
+        {"conn_id", "INT", null},
+        {"graphspace", "VARCHAR(48)", null},
+        {"graph", "VARCHAR(48)", null},
+        {"type", "VARCHAR(48)", null}
+    };
     private static final String[] LOAD_OPTION_CREDENTIALS = {
         "password", "token", "pdToken", "trustStoreToken"
     };
+    private static final String[][] REQUIRED_INDEXES = {
+        {"execute_history", "execute_history_graph_create_time",
+         "graphspace", "graph", "create_time"},
+        {"file_mapping", "file_mapping_job_id", "job_id"},
+        {"load_task", "load_task_job_id", "job_id"},
+        {"job_manager", "job_manager_graph_create_time",
+         "graphspace", "graph", "create_time"},
+        {"async_task", "async_task_graph", "graphspace", "graph"}
+    };
     private static final ObjectMapper JSON = new ObjectMapper();
 
-    @Autowired
-    private DataSource dataSource;
+    @Override
+    public int getOrder() {
+        // Boot's DataSourceInitializerPostProcessor runs at HIGHEST + 1.
+        return Ordered.HIGHEST_PRECEDENCE;
+    }
 
     @Override
-    public void run(ApplicationArguments args) throws Exception {
-        try (Connection conn = this.dataSource.getConnection()) {
+    public Object postProcessAfterInitialization(Object bean, String beanName)
+            throws BeansException {
+        if (!(bean instanceof DataSource)) {
+            return bean;
+        }
+        try (Connection conn = ((DataSource) bean).getConnection()) {
             this.migrate(conn);
+        } catch (SQLException e) {
+            throw new BeanInitializationException(
+                      "Failed to migrate Hubble database schema", e);
         }
+        return bean;
     }
 
     public void migrate(Connection conn) throws SQLException {
@@ -73,9 +129,487 @@ public class DatabaseSchemaMigrator implements ApplicationRunner {
         }
 
         this.migrateExecuteHistoryFailureReason(conn);
+        this.addMissingColumns(conn, EXECUTE_HISTORY_TABLE,
+                               EXECUTE_HISTORY_COLUMNS);
+        this.addMissingColumns(conn, GREMLIN_COLLECTION_TABLE,
+                               GREMLIN_COLLECTION_COLUMNS);
+        this.migrateLegacyQueryScopes(conn);
+        this.migrateGremlinCollectionUniqueConstraint(conn);
+        this.addMissingIndexes(conn);
         this.removeLegacyLoadTaskCredentials(conn);
     }
 
+    private void migrateLegacyQueryScopes(Connection conn)
+            throws SQLException {
+        List<LegacyScope> scopes = this.legacyScopes(conn);
+        LegacyScope fallback = scopes.isEmpty() ?
+                               new LegacyScope(null, DEFAULT_GRAPHSPACE,
+                                               DEFAULT_GRAPH) : scopes.get(0);
+        int updated = 0;
+        if (this.scopeColumnsExist(conn, EXECUTE_HISTORY_TABLE)) {
+            updated += this.backfillScopes(conn, EXECUTE_HISTORY_TABLE, scopes,
+                                           fallback);
+        }
+        if (this.scopeColumnsExist(conn, GREMLIN_COLLECTION_TABLE) &&
+            this.columnExists(conn, GREMLIN_COLLECTION_TABLE, "type")) {
+            updated += this.backfillScopes(conn, GREMLIN_COLLECTION_TABLE,
+                                           scopes, fallback);
+            try (PreparedStatement statement = conn.prepareStatement(
+                    "UPDATE `gremlin_collection` SET `type` = ? " +
+                    "WHERE `type` IS NULL OR `type` = ''")) {
+                statement.setString(1, GREMLIN_TYPE);
+                updated += statement.executeUpdate();
+            }
+        }
+        if (updated > 0) {
+            log.info("Recovered scope for {} legacy query records", updated);
+        }
+    }
+
+    boolean scopeColumnsExist(Connection conn, String table)
+            throws SQLException {
+        return this.tableExists(conn, table) &&
+               this.columnExists(conn, table, "conn_id") &&
+               this.columnExists(conn, table, "graphspace") &&
+               this.columnExists(conn, table, "graph");
+    }
+
+    private int backfillScopes(Connection conn, String table,
+                               List<LegacyScope> scopes,
+                               LegacyScope fallback) throws SQLException {
+        int updated = 0;
+        for (LegacyScope scope : scopes) {
+            updated += this.backfillConnectionScope(conn, table, scope);
+        }
+        try (PreparedStatement statement = conn.prepareStatement(
+                String.format("UPDATE `%s` SET `graphspace` = ? " +
+                              "WHERE `graphspace` IS NULL OR " +
+                              "`graphspace` = ''", table))) {
+            statement.setString(1, fallback.graphspace);
+            updated += statement.executeUpdate();
+        }
+        try (PreparedStatement statement = conn.prepareStatement(
+                String.format("UPDATE `%s` SET `graph` = ? " +
+                              "WHERE `graph` IS NULL OR `graph` = ''",
+                              table))) {
+            statement.setString(1, fallback.graph);
+            updated += statement.executeUpdate();
+        }
+        return updated;
+    }
+
+    private int backfillConnectionScope(Connection conn, String table,
+                                        LegacyScope scope) throws SQLException {
+        int updated = 0;
+        try (PreparedStatement statement = conn.prepareStatement(
+                String.format("UPDATE `%s` SET `graphspace` = ? WHERE " +
+                              "`conn_id` = ? AND (`graphspace` IS NULL OR " +
+                              "`graphspace` = '')", table))) {
+            statement.setString(1, scope.graphspace);
+            statement.setInt(2, scope.connectionId);
+            updated += statement.executeUpdate();
+        }
+        try (PreparedStatement statement = conn.prepareStatement(
+                String.format("UPDATE `%s` SET `graph` = ? WHERE " +
+                              "`conn_id` = ? AND (`graph` IS NULL OR " +
+                              "`graph` = '')", table))) {
+            statement.setString(1, scope.graph);
+            statement.setInt(2, scope.connectionId);
+            updated += statement.executeUpdate();
+        }
+        return updated;
+    }
+
+    private List<LegacyScope> legacyScopes(Connection conn)
+            throws SQLException {
+        List<LegacyScope> scopes = new ArrayList<>();
+        if (!this.tableExists(conn, "graph_connection") ||
+            !this.columnExists(conn, "graph_connection", "id") ||
+            !this.columnExists(conn, "graph_connection", "graph")) {
+            return scopes;
+        }
+
+        boolean hasGraphspace = this.columnExists(conn, "graph_connection",
+                                                  "graphspace");
+        String sql = hasGraphspace ?
+                     "SELECT `id`, `graphspace`, `graph` FROM " +
+                     "`graph_connection` ORDER BY `id`" :
+                     "SELECT `id`, `graph` FROM `graph_connection` " +
+                     "ORDER BY `id`";
+        try (Statement statement = conn.createStatement();
+             ResultSet rows = statement.executeQuery(sql)) {
+            while (rows.next()) {
+                Integer connectionId = rows.getInt(1);
+                String graphspace = hasGraphspace ? rows.getString(2) : null;
+                String graph = rows.getString(hasGraphspace ? 3 : 2);
+                scopes.add(new LegacyScope(
+                        connectionId,
+                        blank(graphspace) ? DEFAULT_GRAPHSPACE : graphspace,
+                        blank(graph) ? DEFAULT_GRAPH : graph));
+            }
+        }
+        return scopes;
+    }
+
+    private void migrateGremlinCollectionUniqueConstraint(Connection conn)
+            throws SQLException {
+        if (!this.tableExists(conn, GREMLIN_COLLECTION_TABLE) ||
+            !this.columnExists(conn, GREMLIN_COLLECTION_TABLE,
+                               "graphspace") ||
+            !this.columnExists(conn, GREMLIN_COLLECTION_TABLE, "graph") ||
+            !this.columnExists(conn, GREMLIN_COLLECTION_TABLE, "name") ||
+            !this.columnExists(conn, GREMLIN_COLLECTION_TABLE, "type")) {
+            return;
+        }
+
+        String product = conn.getMetaData().getDatabaseProductName();
+        String normalizedProduct = product.toLowerCase(Locale.ROOT);
+        if (!normalizedProduct.contains("h2") &&
+            !normalizedProduct.contains("mysql") &&
+            !normalizedProduct.contains("mariadb")) {
+            log.warn("Skip gremlin_collection unique-key migration for " +
+                     "unsupported database product {}", product);
+            return;
+        }
+        this.renameLegacyCollectionCollisions(conn);
+        List<String> obsolete = normalizedProduct.contains("h2") ?
+                                this.h2ObsoleteUniqueConstraints(conn) :
+                                this.mysqlObsoleteUniqueIndexes(conn, product);
+        for (String name : obsolete) {
+            try (Statement statement = conn.createStatement()) {
+                statement.execute(dropUniqueKeySql(product, name));
+            }
+            log.info("Removed obsolete gremlin_collection unique key {}",
+                     name);
+        }
+
+        Map<String, List<String>> uniqueIndexes = this.uniqueIndexes(conn,
+                                                      GREMLIN_COLLECTION_TABLE);
+        if (this.hasScopeUniqueIndex(uniqueIndexes)) {
+            return;
+        }
+        try (Statement statement = conn.createStatement()) {
+            statement.execute("CREATE UNIQUE INDEX `" +
+                              GREMLIN_COLLECTION_SCOPE_INDEX + "` ON `" +
+                              GREMLIN_COLLECTION_TABLE + "`(" +
+                              "`graphspace`, `graph`, `name`, `type`)");
+        }
+        log.info("Added scoped gremlin_collection unique index {}",
+                 GREMLIN_COLLECTION_SCOPE_INDEX);
+    }
+
+    private void renameLegacyCollectionCollisions(Connection conn)
+            throws SQLException {
+        List<LegacyCollectionCollision> collisions = new ArrayList<>();
+        try (Statement statement = conn.createStatement();
+             ResultSet rows = statement.executeQuery(
+                     "SELECT `graphspace`, `graph`, `name`, `type` FROM " +
+                     "`gremlin_collection` GROUP BY `graphspace`, `graph`, " +
+                     "`name`, `type` HAVING COUNT(*) > 1")) {
+            while (rows.next()) {
+                collisions.add(new LegacyCollectionCollision(
+                        rows.getString(1), rows.getString(2),
+                        rows.getString(3), rows.getString(4)));
+            }
+        }
+
+        for (LegacyCollectionCollision collision : collisions) {
+            List<LegacyCollectionRow> duplicateRows =
+                    this.legacyCollectionRows(conn, collision);
+            for (int i = 1; i < duplicateRows.size(); i++) {
+                LegacyCollectionRow row = duplicateRows.get(i);
+                String name = this.uniqueLegacyCollectionName(conn, collision,
+                                                              row);
+                try (PreparedStatement statement = conn.prepareStatement(
+                        "UPDATE `gremlin_collection` SET `name` = ? " +
+                        "WHERE `id` = ?")) {
+                    statement.setString(1, name);
+                    statement.setInt(2, row.id);
+                    statement.executeUpdate();
+                }
+                log.warn("Renamed colliding legacy saved query {} (id {}) " +
+                         "to {} while recovering graph scope", collision.name,
+                         row.id, name);
+            }
+        }
+    }
+
+    private List<LegacyCollectionRow> legacyCollectionRows(
+            Connection conn, LegacyCollectionCollision collision)
+            throws SQLException {
+        List<LegacyCollectionRow> rows = new ArrayList<>();
+        try (PreparedStatement statement = conn.prepareStatement(
+                "SELECT `id`, `conn_id` FROM `gremlin_collection` WHERE " +
+                "`graphspace` = ? AND `graph` = ? AND `name` = ? AND " +
+                "`type` = ? ORDER BY `id`")) {
+            statement.setString(1, collision.graphspace);
+            statement.setString(2, collision.graph);
+            statement.setString(3, collision.name);
+            statement.setString(4, collision.type);
+            try (ResultSet result = statement.executeQuery()) {
+                while (result.next()) {
+                    int connectionId = result.getInt(2);
+                    rows.add(new LegacyCollectionRow(
+                            result.getInt(1),
+                            result.wasNull() ? null : connectionId));
+                }
+            }
+        }
+        return rows;
+    }
+
+    private String uniqueLegacyCollectionName(
+            Connection conn, LegacyCollectionCollision collision,
+            LegacyCollectionRow row) throws SQLException {
+        for (int attempt = 0; ; attempt++) {
+            String suffix = "_" + row.id +
+                            (attempt == 0 ? "" : "_" + attempt);
+            int baseLength = Math.min(collision.name.length(),
+                                      COLLECTION_NAME_LENGTH - suffix.length());
+            String candidate = collision.name.substring(0, baseLength) + suffix;
+            if (!this.legacyCollectionNameExists(conn, collision, row,
+                                                 candidate)) {
+                return candidate;
+            }
+        }
+    }
+
+    private boolean legacyCollectionNameExists(
+            Connection conn, LegacyCollectionCollision collision,
+            LegacyCollectionRow row, String name) throws SQLException {
+        String connectionPredicate = row.connectionId == null ?
+                                     "`conn_id` IS NULL" : "`conn_id` = ?";
+        try (PreparedStatement statement = conn.prepareStatement(
+                "SELECT 1 FROM `gremlin_collection` WHERE `name` = ? AND " +
+                "((`graphspace` = ? AND `graph` = ? AND `type` = ?) OR " +
+                connectionPredicate + ")")) {
+            statement.setString(1, name);
+            statement.setString(2, collision.graphspace);
+            statement.setString(3, collision.graph);
+            statement.setString(4, collision.type);
+            if (row.connectionId != null) {
+                statement.setInt(5, row.connectionId);
+            }
+            try (ResultSet result = statement.executeQuery()) {
+                return result.next();
+            }
+        }
+    }
+
+    private List<String> h2ObsoleteUniqueConstraints(Connection conn)
+            throws SQLException {
+        List<String> constraints = new ArrayList<>();
+        try (PreparedStatement statement = conn.prepareStatement(
+                "SELECT `CONSTRAINT_NAME`, `COLUMN_LIST` FROM " +
+                "`INFORMATION_SCHEMA`.`CONSTRAINTS` WHERE " +
+                "UPPER(`TABLE_NAME`) = ? AND `CONSTRAINT_TYPE` = 'UNIQUE'")) {
+            statement.setString(1,
+                                GREMLIN_COLLECTION_TABLE.toUpperCase(Locale.ROOT));
+            try (ResultSet rows = statement.executeQuery()) {
+                while (rows.next()) {
+                    if (obsoleteUniqueColumns(rows.getString(2))) {
+                        constraints.add(rows.getString(1));
+                    }
+                }
+            }
+        }
+        return constraints;
+    }
+
+    private List<String> mysqlObsoleteUniqueIndexes(Connection conn,
+                                                      String product)
+            throws SQLException {
+        String normalized = product.toLowerCase(Locale.ROOT);
+        if (!normalized.contains("mysql") &&
+            !normalized.contains("mariadb")) {
+            log.warn("Skip gremlin_collection unique-key migration for " +
+                     "unsupported database product {}", product);
+            return new ArrayList<>();
+        }
+        List<String> indexes = new ArrayList<>();
+        for (Map.Entry<String, List<String>> entry :
+             this.uniqueIndexes(conn, GREMLIN_COLLECTION_TABLE).entrySet()) {
+            if (obsoleteUniqueColumns(String.join(",", entry.getValue()))) {
+                indexes.add(entry.getKey());
+            }
+        }
+        return indexes;
+    }
+
+    private Map<String, List<String>> uniqueIndexes(Connection conn,
+                                                     String table)
+            throws SQLException {
+        Map<String, SortedMap<Short, String>> columns = new LinkedHashMap<>();
+        DatabaseMetaData metaData = conn.getMetaData();
+        String[] tables = {table, table.toUpperCase(Locale.ROOT)};
+        for (String tableName : tables) {
+            try (ResultSet rows = metaData.getIndexInfo(conn.getCatalog(), null,
+                                                        tableName, true,
+                                                        false)) {
+                while (rows.next()) {
+                    String index = rows.getString("INDEX_NAME");
+                    String column = rows.getString("COLUMN_NAME");
+                    if (index == null || column == null ||
+                        "PRIMARY".equalsIgnoreCase(index) ||
+                        index.toUpperCase(Locale.ROOT).startsWith("PRIMARY_KEY")) {
+                        continue;
+                    }
+                    short position = rows.getShort("ORDINAL_POSITION");
+                    columns.computeIfAbsent(index, key -> new TreeMap<>())
+                           .put(position, column.toLowerCase(Locale.ROOT));
+                }
+            }
+            if (!columns.isEmpty()) {
+                break;
+            }
+        }
+        Map<String, List<String>> indexes = new LinkedHashMap<>();
+        for (Map.Entry<String, SortedMap<Short, String>> entry :
+             columns.entrySet()) {
+            indexes.put(entry.getKey(),
+                        new ArrayList<>(entry.getValue().values()));
+        }
+        return indexes;
+    }
+
+    private boolean hasScopeUniqueIndex(Map<String, List<String>> indexes) {
+        for (List<String> columns : indexes.values()) {
+            if ("graphspace,graph,name,type".equals(String.join(",", columns))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean obsoleteUniqueColumns(String columns) {
+        String normalized = columns.toLowerCase(Locale.ROOT)
+                                   .replace("`", "")
+                                   .replace(" ", "");
+        return "name".equals(normalized) ||
+               "conn_id,name".equals(normalized);
+    }
+
+    static String dropUniqueKeySql(String productName, String name) {
+        String operation = productName.toLowerCase(Locale.ROOT).contains("h2") ?
+                           "DROP CONSTRAINT" : "DROP INDEX";
+        return "ALTER TABLE `gremlin_collection` " + operation + " `" +
+               name.replace("`", "``") + "`";
+    }
+
+    private static boolean blank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    /**
+     * Add the columns a legacy Hubble database is missing. `CREATE TABLE IF
+     * NOT EXISTS` is a no-op against a pre-existing table, so an H2 file
+     * created by an older Hubble keeps its old column set and every query
+     * touching the newer columns fails. Each column is added nullable and
+     * then backfilled, which keeps the statement portable and idempotent:
+     * on a fresh database every column already exists and nothing is run.
+     */
+    private void addMissingColumns(Connection conn, String table,
+                                   String[][] columns) throws SQLException {
+        if (!this.tableExists(conn, table)) {
+            return;
+        }
+
+        String product = conn.getMetaData().getDatabaseProductName()
+                             .toLowerCase(Locale.ROOT);
+        if (!product.contains("h2") && !product.contains("mysql") &&
+            !product.contains("mariadb")) {
+            log.warn("Skip {} column migration for unsupported database " +
+                     "product {}", table,
+                     conn.getMetaData().getDatabaseProductName());
+            return;
+        }
+
+        for (String[] column : columns) {
+            String name = column[0];
+            boolean added = false;
+            if (!this.columnExists(conn, table, name)) {
+                try (Statement statement = conn.createStatement()) {
+                    statement.execute(String.format(
+                            "ALTER TABLE `%s` ADD COLUMN `%s` %s DEFAULT NULL",
+                            table, name, column[1]));
+                }
+                added = true;
+            }
+            // Backfill unconditionally. The ALTER and the UPDATE commit
+            // separately, so a crash between them would leave the column
+            // present but permanently NULL: the next startup would see the
+            // column already exists and skip the backfill for good. The
+            // statement only touches NULL rows, so repeating it is a no-op.
+            if (column[2] != null) {
+                try (Statement statement = conn.createStatement()) {
+                    statement.executeUpdate(String.format(
+                            "UPDATE `%s` SET `%s` = %s WHERE `%s` IS NULL",
+                            table, name, column[2], name));
+                }
+            }
+            if (added) {
+                log.info("Added {}.{} {}", table, name, column[1]);
+            }
+        }
+    }
+
+    private void addMissingIndexes(Connection conn) throws SQLException {
+        for (String[] definition : REQUIRED_INDEXES) {
+            String table = definition[0];
+            String index = definition[1];
+            if (!this.tableExists(conn, table) ||
+                this.indexExists(conn, table, index)) {
+                continue;
+            }
+            boolean columnsExist = true;
+            for (int i = 2; i < definition.length; i++) {
+                columnsExist &= this.columnExists(conn, table, definition[i]);
+            }
+            if (!columnsExist) {
+                continue;
+            }
+
+            StringBuilder columns = new StringBuilder();
+            for (int i = 2; i < definition.length; i++) {
+                if (columns.length() > 0) {
+                    columns.append(", ");
+                }
+                columns.append('`').append(definition[i]).append('`');
+            }
+            try (Statement statement = conn.createStatement()) {
+                statement.execute(String.format(
+                        "CREATE INDEX `%s` ON `%s`(%s)", index, table,
+                        columns));
+            } catch (SQLException e) {
+                if (!this.indexExists(conn, table, index)) {
+                    throw e;
+                }
+                log.info("Index {} on {} was added concurrently", index,
+                         table);
+                continue;
+            }
+            log.info("Added index {} on {}", index, table);
+        }
+    }
+
+    private boolean indexExists(Connection conn, String table, String index)
+            throws SQLException {
+        DatabaseMetaData metaData = conn.getMetaData();
+        String[] tables = {table, table.toUpperCase(Locale.ROOT)};
+        for (String tableName : tables) {
+            try (ResultSet rs = metaData.getIndexInfo(conn.getCatalog(), null,
+                                                      tableName, false,
+                                                      false)) {
+                while (rs.next()) {
+                    if (index.equalsIgnoreCase(rs.getString("INDEX_NAME"))) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
     private void migrateFileMappingPath(Connection conn, int currentLength)
             throws SQLException {
         String sql = this.alterFileMappingPathSql(
@@ -187,6 +721,24 @@ public class DatabaseSchemaMigrator implements ApplicationRunner {
         return false;
     }
 
+    private boolean columnExists(Connection conn, String table, String column)
+            throws SQLException {
+        DatabaseMetaData metaData = conn.getMetaData();
+        String[] tables = {table, table.toUpperCase(Locale.ROOT)};
+        String[] columns = {column, column.toUpperCase(Locale.ROOT)};
+        for (String tableName : tables) {
+            for (String columnName : columns) {
+                try (ResultSet rs = metaData.getColumns(null, null, tableName,
+                                                        columnName)) {
+                    if (rs.next()) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
     private int columnSize(Connection conn, String table, String column)
             throws SQLException {
         DatabaseMetaData metaData = conn.getMetaData();
@@ -217,4 +769,45 @@ public class DatabaseSchemaMigrator implements ApplicationRunner {
         }
         return null;
     }
+
+    private static final class LegacyScope {
+
+        private final Integer connectionId;
+        private final String graphspace;
+        private final String graph;
+
+        private LegacyScope(Integer connectionId, String graphspace,
+                            String graph) {
+            this.connectionId = connectionId;
+            this.graphspace = graphspace;
+            this.graph = graph;
+        }
+    }
+
+    private static final class LegacyCollectionCollision {
+
+        private final String graphspace;
+        private final String graph;
+        private final String name;
+        private final String type;
+
+        private LegacyCollectionCollision(String graphspace, String graph,
+                                          String name, String type) {
+            this.graphspace = graphspace;
+            this.graph = graph;
+            this.name = name;
+            this.type = type;
+        }
+    }
+
+    private static final class LegacyCollectionRow {
+
+        private final int id;
+        private final Integer connectionId;
+
+        private LegacyCollectionRow(int id, Integer connectionId) {
+            this.id = id;
+            this.connectionId = connectionId;
+        }
+    }
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/GraphController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/GraphController.java
index 1acb0d5b9..ea4d38611 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/GraphController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/GraphController.java
@@ -176,31 +176,41 @@ public class GraphController extends BaseController {
                                List<String> elementIds) {
         HugeClient client = this.authClient(graphSpace, graph);
         ArrayList<ElementEditHistory> list = new ArrayList<>();
-        HashSet<String> set = new HashSet<>(elementIds);
-        if ("VERTEX".equals(type)) {
-            for (String vertexId : set) {
-                vertexId = UriUtils.decode(vertexId, Constant.CHARSET);
-                Vertex vertex = client.graph().getVertex(vertexId);
-                String label = vertex.label();
-                this.graphService.deleteVertex(client, vertexId);
-                list.add(getEditEleHistory(graphSpace, graph, vertexId, label,
-                                  vertex.properties().size(), OptionType.DELETE, ""));
+        HashSet<String> set = new HashSet<>();
+        for (String elementId : elementIds) {
+            set.add(UriUtils.decode(elementId, Constant.CHARSET));
+        }
+        try {
+            if ("VERTEX".equals(type)) {
+                for (String vertexId : set) {
+                    Vertex vertex = client.graph().getVertex(vertexId);
+                    String label = vertex.label();
+                    this.graphService.deleteVertex(client, vertexId);
+                    list.add(getEditEleHistory(graphSpace, graph, vertexId, label,
+                                      vertex.properties().size(), OptionType.DELETE, ""));
+                }
+            } else if ("EDGE".equals(type)) {
+                // Dedupe edge ids the same way as vertex ids, deleting the
+                // same edge twice fails after the first delete
+                for (String edgeId : set) {
+                    Edge edge = client.graph().getEdge(edgeId);
+                    String label = edge.label();
+                    this.graphService.deleteEdge(client, edgeId);
+                    list.add(getEditEleHistory(graphSpace, graph, edgeId, label,
+                                               edge.properties().size(),
+                                               OptionType.DELETE, ""));
+                }
+            } else {
+                throw new IllegalArgumentException(
+                        "type must in [VERTEX, EDGE], but got '" + type + "'");
             }
-        } else if ("EDGE".equals(type)) {
-            for (String edgeId : elementIds) {
-                edgeId = UriUtils.decode(edgeId, Constant.CHARSET);
-                Edge edge = client.graph().getEdge(edgeId);
-                String label = edge.label();
-                this.graphService.deleteEdge(client, edgeId);
-                list.add(getEditEleHistory(graphSpace, graph, edgeId, label,
-                                           edge.properties().size(),
-                                           OptionType.DELETE, ""));
+        } finally {
+            // Persist the history of what was actually deleted even if the
+            // batch failed part way through
+            if (!list.isEmpty()) {
+                editEleHisService.add(list);
             }
-        } else {
-            throw new IllegalArgumentException(
-                    "type must in [VERTEX, EDGE], but got '" + type + "'");
         }
-        editEleHisService.add(list);
     }
 
     @GetMapping("edgelabel/{label}")
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java
index b464063c4..7b7f97050 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java
@@ -83,6 +83,7 @@ import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
@@ -158,6 +159,7 @@ public class IngestController extends BaseController {
         Long limit = this.config.get(HubbleOptions.UPLOAD_SINGLE_FILE_SIZE_LIMIT);
         Ex.check(file.getSize() <= limit, "load.upload.file.exceed-single-size",
                  limit);
+        this.checkFileFormatValid(fileName);
 
         Path uploadRoot = Paths.get(this.config.get(HubbleOptions.UPLOAD_FILE_LOCATION))
                                .toAbsolutePath()
@@ -504,6 +506,26 @@ public class IngestController extends BaseController {
 
     // ===== Helpers =====
 
+    /**
+     * Same format-whitelist check as
+     * FileUploadController#checkFileValid
+     */
+    private void checkFileFormatValid(String fileName) {
+        String format = FilenameUtils.getExtension(fileName);
+        Ex.check(StringUtils.isNotBlank(format),
+                 "load.upload.file.format.unsupported");
+        List<String> formatWhiteList = this.config.get(
+                                       HubbleOptions.UPLOAD_FILE_FORMAT_LIST);
+        String normalizedFormat = format.toLowerCase(Locale.ROOT);
+        boolean supported = formatWhiteList != null &&
+                            formatWhiteList.stream()
+                                           .filter(StringUtils::isNotBlank)
+                                           .map(String::trim)
+                                           .map(item -> item.toLowerCase(Locale.ROOT))
+                                           .anyMatch(normalizedFormat::equals);
+        Ex.check(supported, "load.upload.file.format.unsupported");
+    }
+
     private GraphConnection graphConnection(String graphSpace, String graph) {
         GraphConnection connection = new GraphConnection();
         connection.setCluster(config.get(HubbleOptions.PD_CLUSTER));
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java
index a9642e0ac..39674b59e 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java
@@ -19,17 +19,21 @@
 package org.apache.hugegraph.controller.load;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hugegraph.common.Constant;
 import org.apache.hugegraph.controller.BaseController;
 import org.apache.hugegraph.driver.HugeClient;
 import org.apache.hugegraph.entity.enums.JobStatus;
+import org.apache.hugegraph.entity.enums.LoadStatus;
 import org.apache.hugegraph.entity.load.FileMapping;
 import org.apache.hugegraph.entity.load.FileSetting;
 import org.apache.hugegraph.entity.load.ElementMapping;
 import org.apache.hugegraph.entity.load.JobManager;
 import org.apache.hugegraph.entity.load.LoadParameter;
+import org.apache.hugegraph.entity.load.LoadTask;
 import org.apache.hugegraph.entity.load.VertexMapping;
 import org.apache.hugegraph.entity.load.EdgeMapping;
 import org.apache.hugegraph.entity.schema.EdgeLabelEntity;
@@ -37,6 +41,7 @@ import org.apache.hugegraph.entity.schema.VertexLabelEntity;
 import org.apache.hugegraph.exception.ExternalException;
 import org.apache.hugegraph.service.load.FileMappingService;
 import org.apache.hugegraph.service.load.JobManagerService;
+import org.apache.hugegraph.service.load.LoadTaskService;
 import org.apache.hugegraph.service.schema.EdgeLabelService;
 import org.apache.hugegraph.service.schema.VertexLabelService;
 import org.apache.hugegraph.util.CollectionUtil;
@@ -54,6 +59,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
@@ -71,6 +77,8 @@ public class FileMappingController extends BaseController {
     private FileMappingService service;
     @Autowired
     private JobManagerService jobService;
+    @Autowired
+    private LoadTaskService taskService;
 
     @GetMapping
     public IPage<FileMapping> list(@PathVariable("graphspace") String graphSpace,
@@ -108,9 +116,9 @@ public class FileMappingController extends BaseController {
         if (mapping == null) {
             throw new ExternalException("load.file-mapping.not-exist.id", id);
         }
+        this.checkNoRunningTask(jobId, ImmutableSet.of(mapping.getId()));
 
-        this.service.deleteDiskFile(mapping);
-        this.service.remove(id);
+        this.jobService.deleteMappings(jobId, ImmutableList.of(mapping));
     }
 
     @DeleteMapping
@@ -119,9 +127,14 @@ public class FileMappingController extends BaseController {
                       @PathVariable("jobId") int jobId) {
         List<FileMapping> mappings = this.service.listByJob(graphSpace, graph,
                                                             jobId);
+        Set<Integer> fileIds = new HashSet<>();
         for (FileMapping mapping : mappings) {
-            this.service.remove(mapping.getId());
+            fileIds.add(mapping.getId());
         }
+        // Check them all upfront, don't delete part of the mappings then fail
+        this.checkNoRunningTask(jobId, fileIds);
+
+        this.jobService.deleteMappings(jobId, mappings);
     }
 
     @PostMapping("{id}/file-setting")
@@ -324,6 +337,27 @@ public class FileMappingController extends BaseController {
         return jobEntity;
     }
 
+    /**
+     * Keep source and progress files while a task can still run, resume, or
+     * retry from them.
+     */
+    private void checkNoRunningTask(int jobId, Set<Integer> fileIds) {
+        for (LoadTask task : this.taskService.taskListByJob(jobId)) {
+            LoadStatus status = task.getStatus();
+            boolean needsSource = status == LoadStatus.RUNNING ||
+                                  status == LoadStatus.PAUSED ||
+                                  status == LoadStatus.FAILED ||
+                                  status == LoadStatus.STOPPED;
+            if (needsSource &&
+                task.getFileId() != null &&
+                fileIds.contains(task.getFileId())) {
+                throw new ExternalException(
+                          "load.file-mapping.delete.task-in-running",
+                          task.getFileId());
+            }
+        }
+    }
+
     private void checkVertexMappingValid(HugeClient client,
                                          VertexMapping vertexMapping,
                                          FileMapping fileMapping) {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java
index 8efac6f53..99c36da84 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java
@@ -92,15 +92,26 @@ public class FileUploadController extends BaseController {
         Ex.check(CollectionUtil.allUnique(fileNames),
                  "load.upload.file.duplicate-name");
         Map<String, String> tokens = new HashMap<>();
-        for (String fileName : fileNames) {
-            this.checkFileNameValid(fileName);
-            String token = this.service.generateFileToken(fileName);
-            Ex.check(!this.uploadingTokenLocks().containsKey(token),
-                     "load.upload.file.token.existed");
-            this.uploadingTokenLocks().put(token, new ReentrantReadWriteLock());
-            tokens.put(fileName, token);
+        Map<String, ReadWriteLock> reservations = new HashMap<>();
+        try {
+            for (String fileName : fileNames) {
+                this.checkFileNameValid(fileName);
+                String token = this.service.generateFileToken(fileName);
+                ReadWriteLock lock = new ReentrantReadWriteLock();
+                // Atomic reservation to avoid a containsKey-then-put race
+                ReadWriteLock previous = this.uploadingTokenLocks()
+                                             .putIfAbsent(token, lock);
+                Ex.check(previous == null, "load.upload.file.token.existed");
+                reservations.put(token, lock);
+                tokens.put(fileName, token);
+            }
+            return tokens;
+        } catch (RuntimeException e) {
+            reservations.forEach((token, lock) -> {
+                this.uploadingTokenLocks().remove(token, lock);
+            });
+            throw e;
         }
-        return tokens;
     }
 
     @PostMapping
@@ -198,15 +209,7 @@ public class FileUploadController extends BaseController {
                 mapping.setTotalLines(FileUtil.countLines(mapping.getPath()));
                 mapping.setTotalSize(actualFileSize);
 
-                // Move to the directory corresponding to the file mapping Id
-                String newPath = this.service.moveToNextLevelDir(mapping);
-                // Update file mapping stored path
-                mapping.setPath(newPath);
-                this.service.update(mapping);
-                // Update Job Manager size
-                long jobSize = currentJob.getJobSize() + mapping.getTotalSize();
-                currentJob.setJobSize(jobSize);
-                this.jobService.update(currentJob);
+                this.finalizeUpload(mapping);
                 result.setId(mapping.getId());
                 // Remove uploading file token
                 this.uploadingTokenLocks().remove(token);
@@ -217,6 +220,33 @@ public class FileUploadController extends BaseController {
         }
     }
 
+    private void finalizeUpload(FileMapping mapping) {
+        String originalPath = mapping.getPath();
+        String movedPath = this.service.moveToNextLevelDir(mapping);
+        mapping.setPath(movedPath);
+        try {
+            this.jobService.completeUpload(mapping);
+        } catch (RuntimeException failure) {
+            mapping.setPath(originalPath);
+            mapping.setFileStatus(FileMappingStatus.UPLOADING);
+            try {
+                this.service.restoreMovedUpload(movedPath, originalPath);
+            } catch (RuntimeException compensationFailure) {
+                // Keep the database cleanup record pointed at the real file
+                // when restoring the original path is impossible.
+                mapping.setPath(movedPath);
+                mapping.setFileStatus(FileMappingStatus.FAILURE);
+                try {
+                    this.service.update(mapping);
+                } catch (RuntimeException recordFailure) {
+                    compensationFailure.addSuppressed(recordFailure);
+                }
+                failure.addSuppressed(compensationFailure);
+            }
+            throw failure;
+        }
+    }
+
     @DeleteMapping
     public Boolean delete(@PathVariable("graphspace") String graphSpace,
                           @PathVariable("graph") String graph,
@@ -239,12 +269,8 @@ public class FileUploadController extends BaseController {
             lock.writeLock().lock();
         }
         try {
-            this.service.deleteDiskFile(mapping);
-            log.info("Prepare to remove file mapping {}", mapping.getId());
-            this.service.remove(mapping.getId());
-            long jobSize = jobEntity.getJobSize() - mapping.getTotalSize();
-            jobEntity.setJobSize(jobSize);
-            this.jobService.update(jobEntity);
+            this.jobService.deleteMappings(jobId,
+                                           Collections.singletonList(mapping));
             if (lock != null) {
                 this.uploadingTokenLocks().remove(token);
             }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java
index e12146128..f3c58a3e5 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java
@@ -201,9 +201,15 @@ public class JobManagerController {
             if (task.getStatus() == LoadStatus.FAILED) {
                 FileMapping mapping = this.fmService.get(graphSpace, graph, id,
                                                           fileId);
-                reason = this.taskService.readLoadFailedReason(mapping);
+                if (mapping == null) {
+                    // The file mapping may have been deleted while the task
+                    // still references its file id
+                    reason = LoadTaskService.NO_ERROR_FILE_REASON;
+                } else {
+                    reason = this.taskService.readLoadFailedReason(mapping);
+                }
             }
-            reasonResult.setTaskId(task.getJobId());
+            reasonResult.setTaskId(task.getId());
             reasonResult.setFileId(task.getFileId());
             reasonResult.setFileName(task.getFileName());
             reasonResult.setReason(reason);
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java
index d16cacc94..66f534c18 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java
@@ -137,6 +137,12 @@ public class LoadTaskController extends BaseController {
         if (task == null) {
             throw new ExternalException("load.task.not-exist.id", id);
         }
+        /*
+         * Removing a task that is still importing would leave the loader
+         * thread writing to the graph with no task to report progress to
+         */
+        Ex.check(!task.getStatus().inRunning(),
+                 "load.task.delete.task-in-running", id);
         this.service.remove(id);
     }
 
@@ -224,7 +230,7 @@ public class LoadTaskController extends BaseController {
         Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null,
                  "load.task.not-exist.id", taskId);
         Ex.check(jobEntity.getJobStatus() == JobStatus.LOADING,
-                 "load.task.pause.no-permission");
+                 "load.task.resume.no-permission");
         try {
             return this.service.resume(taskId, this.getToken());
         } finally {
@@ -244,7 +250,7 @@ public class LoadTaskController extends BaseController {
         Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null,
                  "load.task.not-exist.id", taskId);
         Ex.check(jobEntity.getJobStatus() == JobStatus.LOADING,
-                 "load.task.pause.no-permission");
+                 "load.task.stop.no-permission");
         try {
             return this.service.stop(taskId);
         } finally {
@@ -264,7 +270,7 @@ public class LoadTaskController extends BaseController {
         Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null,
                  "load.task.not-exist.id", taskId);
         Ex.check(jobEntity.getJobStatus() == JobStatus.LOADING,
-                 "load.task.pause.no-permission");
+                 "load.task.retry.no-permission");
         try {
             return this.service.retry(taskId, this.getToken());
         } finally {
@@ -288,7 +294,13 @@ public class LoadTaskController extends BaseController {
         Integer fileId = task.getFileId();
         FileMapping mapping = this.fmService.get(graphSpace, graph, jobId,
                                                   fileId);
-        String reason = this.service.readLoadFailedReason(mapping);
+        /*
+         * The file mapping may have been deleted while the task still
+         * references its id, there is no error file to read in that case
+         */
+        String reason = mapping == null ?
+                        LoadTaskService.NO_ERROR_FILE_REASON :
+                        this.service.readLoadFailedReason(mapping);
         return Response.builder()
                        .status(Constant.STATUS_OK)
                        .data(reason)
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/HStoreController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/HStoreController.java
index 66cddcf75..f071e8c6e 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/HStoreController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/HStoreController.java
@@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableMap;
 import lombok.extern.log4j.Log4j2;
 import org.apache.hugegraph.service.space.HStoreService;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -59,7 +60,11 @@ public class HStoreController extends BaseController {
                                    defaultValue = "10") int pageSize) {
 
         HugeClient client = this.authClient(null, null);
-        return hStoreService.listPage(client, pageNo, pageSize);
+        try {
+            return hStoreService.listPage(client, pageNo, pageSize);
+        } catch (ServerException e) {
+            throw mapHstoreFailure(e);
+        }
     }
 
     @GetMapping("nodes/{id}")
@@ -67,8 +72,13 @@ public class HStoreController extends BaseController {
         HugeClient client = this.authClient(null, null);
         E.checkNotNull(id, "id");
 
-        List<HStoreNodeInfo.HStorePartitionInfo> partitions =
-                client.hStoreManager().get(id).hStorePartitionInfoList();
+        List<HStoreNodeInfo.HStorePartitionInfo> partitions;
+        try {
+            partitions = client.hStoreManager().get(id)
+                               .hStorePartitionInfoList();
+        } catch (ServerException e) {
+            throw mapHstoreFailure(e, id);
+        }
         return ImmutableMap.of("shards", partitions);
     }
 
@@ -91,8 +101,7 @@ public class HStoreController extends BaseController {
         try {
             status = client.hStoreManager().status();
         } catch (ServerException e) {
-            throw new ServerCapabilityUnavailableException(
-                    "server.capability.hstore-status.unavailable", e);
+            throw mapHstoreFailure(e);
         }
 
         return ImmutableMap.of("status", status);
@@ -104,7 +113,11 @@ public class HStoreController extends BaseController {
     @GetMapping("split")
     public void triggerSplit() {
         HugeClient client = this.authClient(null, null);
-        client.hStoreManager().startSplit();
+        try {
+            client.hStoreManager().startSplit();
+        } catch (ServerException e) {
+            throw mapHstoreFailure(e);
+        }
     }
 
     /**
@@ -124,6 +137,11 @@ public class HStoreController extends BaseController {
         for (String nodeId: nodes) {
             try {
                 client.hStoreManager().nodeStartup(nodeId);
+            } catch (ServerException e) {
+                log.warn("Startup of HStore node {} failed after nodes {} " +
+                         "succeeded", nodeId, successNodes);
+                throw mapHstoreFailure(e, nodeId,
+                                       ImmutableList.copyOf(successNodes));
             } catch (RuntimeException e) {
                 log.info("startup hstore node({}) success", successNodes);
                 log.warn("startup hstore node({}) error", nodeId, e);
@@ -153,6 +171,11 @@ public class HStoreController extends BaseController {
         for (String nodeId: nodes) {
             try {
                 client.hStoreManager().nodeShutdown(nodeId);
+            } catch (ServerException e) {
+                log.warn("Shutdown of HStore node {} failed after nodes {} " +
+                         "succeeded", nodeId, successNodes);
+                throw mapHstoreFailure(e, nodeId,
+                                       ImmutableList.copyOf(successNodes));
             } catch (RuntimeException e) {
                 log.info("shutdown hstore node({}) success", successNodes);
                 log.warn("shutdown hstore node({}) error", nodeId, e);
@@ -164,4 +187,17 @@ public class HStoreController extends BaseController {
             successNodes.add(nodeId);
         }
     }
+
+    static RuntimeException mapHstoreFailure(ServerException exception,
+                                             Object... context) {
+        int status = exception.status();
+        if (status == HttpStatus.NOT_FOUND.value() ||
+            status == HttpStatus.METHOD_NOT_ALLOWED.value() ||
+            status == HttpStatus.NOT_IMPLEMENTED.value()) {
+            return new ServerCapabilityUnavailableException(
+                    "server.capability.hstore-status.unavailable", exception,
+                    context);
+        }
+        return exception;
+    }
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/LogController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/LogController.java
index 4ade014cb..60393f8e2 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/LogController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/LogController.java
@@ -37,6 +37,7 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
 import java.util.Arrays;
 import java.util.List;
 
@@ -79,8 +80,13 @@ public class LogController extends BaseController {
     @PostMapping("export")
     public void export(HttpServletResponse response,
                        @RequestBody LogService.LogReq logReq) {
-        String fileName = String.format("log.txt", logReq.startDatetime,
-                                        logReq.endDatetime);
+        SimpleDateFormat dateFormat =
+                new SimpleDateFormat("yyyyMMddHHmmss");
+        String start = logReq.startDatetime == null ?
+                       "begin" : dateFormat.format(logReq.startDatetime);
+        String end = logReq.endDatetime == null ?
+                     "end" : dateFormat.format(logReq.endDatetime);
+        String fileName = String.format("log_%s_%s.txt", start, end);
 
         response.setCharacterEncoding("UTF-8");
         response.setContentType("application/octet-stream");
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java
index 30acc4c56..ce6fd04af 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java
@@ -258,10 +258,14 @@ public class LoadTask implements Runnable {
         if (this.fileTotalLines == null || this.fileTotalLines == 0) {
             return 0;
         }
-        Ex.check(this.fileTotalLines >= this.fileReadLines,
-                 "The file total lines must be >= read lines, " +
-                 "but got total lines %s, read lines %s",
-                 this.fileTotalLines, this.fileReadLines);
+        // Clamp instead of throwing: this getter runs during JSON
+        // serialization, an exception here would fail the whole response
+        if (this.fileReadLines == null) {
+            return 0;
+        }
+        if (this.fileReadLines >= this.fileTotalLines) {
+            return 100;
+        }
         float actualProgress = (float) this.fileReadLines / this.fileTotalLines;
         return ((int) (actualProgress * 10000)) / 100.0F;
     }
@@ -279,7 +283,7 @@ public class LoadTask implements Runnable {
 
     @JsonProperty("load_rate")
     public String getLoadRate() {
-        long readLines = this.fileReadLines;
+        long readLines = this.fileReadLines == null ? 0L : this.fileReadLines;
         long duration = this.getDuration();
         float rate;
         if (readLines == 0L || duration == 0L) {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ApplicationInfo.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ApplicationInfo.java
index 23b8bafb4..974b43649 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ApplicationInfo.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ApplicationInfo.java
@@ -21,14 +21,18 @@ package org.apache.hugegraph.entity.query;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 import org.apache.hugegraph.annotation.MergeProperty;
 import org.apache.hugegraph.common.AppType;
 import org.apache.hugegraph.common.Mergeable;
 import org.apache.hugegraph.util.JsonUtil;
 
 @Data
+@NoArgsConstructor
+@AllArgsConstructor
 @Builder
 @TableName("app_info")
 public class ApplicationInfo implements Mergeable {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ElementEditHistory.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ElementEditHistory.java
index 811fc160d..a69a295c4 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ElementEditHistory.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/query/ElementEditHistory.java
@@ -32,8 +32,10 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 
 import lombok.Builder;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
+@NoArgsConstructor
 @Builder
 @TableName("edit_history")
 public class ElementEditHistory implements Identifiable, Mergeable {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java
index c64f42786..2a23c422c 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java
@@ -370,12 +370,15 @@ public class ExceptionAdvisor {
                     String origin = result.get("message").toString();
                     List<String> attach =
                             HubbleUtil.uncheckedCast(result.get("attach"));
+                    Object[] args = attach != null ? attach.toArray() :
+                                    new Object[0];
                     message = ErrorCodeMessage.getErrorMessage(code, origin,
-                                                               attach.toArray());
+                                                               args);
                 }
             } catch (Exception e) {
+                // Fall back to the original upstream message, never throw
+                // from inside the exception handler
                 log.error("hubble.error_code_parse_failed");
-                throw new RuntimeException("failed_to_handle_error_code");
             }
         }
         return message;
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/FileMappingMapper.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/FileMappingMapper.java
index bfb4cfd87..69d537755 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/FileMappingMapper.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/FileMappingMapper.java
@@ -19,6 +19,8 @@
 package org.apache.hugegraph.mapper.load;
 
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
 import org.springframework.stereotype.Component;
 
 import org.apache.hugegraph.entity.load.FileMapping;
@@ -27,4 +29,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 @Mapper
 @Component
 public interface FileMappingMapper extends BaseMapper<FileMapping> {
+
+    @Update("UPDATE file_mapping SET job_id = -id " +
+            "WHERE id = #{mappingId} AND job_id = #{jobId}")
+    int detachFromJob(@Param("mappingId") int mappingId,
+                      @Param("jobId") int jobId);
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/JobManagerMapper.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/JobManagerMapper.java
index f1eb255b5..628149c82 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/JobManagerMapper.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/load/JobManagerMapper.java
@@ -21,6 +21,7 @@ package org.apache.hugegraph.mapper.load;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 import org.springframework.stereotype.Component;
 
 import org.apache.hugegraph.entity.load.JobManager;
@@ -31,9 +32,18 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 @Component
 public interface JobManagerMapper extends BaseMapper<JobManager> {
 
-    @Select("SELECT ISNULL(SUM(f.total_size),0) as total_size, " +
-            "ISNULL(SUM(l.duration),0) as duration " +
+    @Select("SELECT IFNULL(SUM(f.total_size),0) as total_size, " +
+            "IFNULL(SUM(l.last_duration + l.curr_duration),0) as duration " +
             "FROM `load_task` as l LEFT JOIN `file_mapping` as f " +
             "ON l.file_id=f.id WHERE l.job_id = #{job_id}")
     JobManagerItem computeSizeDuration(@Param("job_id") int jobId);
+
+    @Update("UPDATE `job_manager` SET `job_size` = `job_size` + #{size} " +
+            "WHERE `id` = #{id}")
+    int increaseJobSize(@Param("id") int id, @Param("size") long size);
+
+    @Update("UPDATE `job_manager` SET `job_size` = CASE " +
+            "WHEN `job_size` >= #{size} THEN `job_size` - #{size} " +
+            "ELSE 0 END WHERE `id` = #{id}")
+    int decreaseJobSize(@Param("id") int id, @Param("size") long size);
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/EditElementHistoryMapper.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/EditElementHistoryMapper.java
index 53badfe49..991e46409 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/EditElementHistoryMapper.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/EditElementHistoryMapper.java
@@ -32,8 +32,12 @@ import java.util.List;
 @Component
 public interface EditElementHistoryMapper extends
                                       BaseMapper<ElementEditHistory> {
-    @Select("SELECT * FROM `edit_history` LIMIT #{limit}")
-    List<ElementEditHistory> queryByLimit(@Param("limit") int limit);
+    @Select("SELECT * FROM `edit_history` WHERE graphspace = #{graphspace} " +
+            "AND graph = #{graph} ORDER BY option_time DESC LIMIT #{limit}")
+    List<ElementEditHistory> queryByLimit(
+            @Param("graphspace") String graphspace,
+            @Param("graph") String graph,
+            @Param("limit") int limit);
 
     @Select("SELECT * FROM `edit_history` WHERE graphspace = #{graphspace} " +
             "AND graph = #{graph} AND element_id = #{elementId}")
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/ExecuteHistoryMapper.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/ExecuteHistoryMapper.java
index 4d62ad362..543f4802f 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/ExecuteHistoryMapper.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/mapper/query/ExecuteHistoryMapper.java
@@ -32,16 +32,53 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 @Component
 public interface ExecuteHistoryMapper extends BaseMapper<ExecuteHistory> {
 
-    // 删除超出限制的记录
+    // 单条 DELETE ... IN (...) 允许携带的最大 id 数, 避免超出数据库
+    // 的报文大小与参数个数上限
+    int DELETE_BATCH_SIZE = 500;
+
+    // 删除超出限制的记录, 按 (graphspace, graph) 分别保留最新的 limit 条
     default void deleteExceedLimit(int limit) {
-        List<Long> ids = findIdsToDelete(limit, limit);
-        if (!ids.isEmpty()) {
-            deleteBatchIds(ids);
+        for (ExecuteHistory scope : findScopes()) {
+            while (true) {
+                int excess = countScope(scope.getGraphspace(),
+                                        scope.getGraph()) - limit;
+                if (excess <= 0) {
+                    break;
+                }
+                int batchSize = Math.min(excess, DELETE_BATCH_SIZE);
+                List<Long> ids = findIdsOldestFirst(scope.getGraphspace(),
+                                                    scope.getGraph(),
+                                                    batchSize);
+                if (ids.isEmpty()) {
+                    break;
+                }
+                // Another scheduler may have deleted the same candidates.
+                // Recheck after selection to avoid consuming retained rows.
+                excess = countScope(scope.getGraphspace(), scope.getGraph()) -
+                         limit;
+                if (excess <= 0) {
+                    break;
+                }
+                deleteBatchIds(ids.subList(0, Math.min(excess, ids.size())));
+            }
         }
     }
 
-    // 查询满足条件的 id 列表
-    @Select("SELECT id FROM `execute_history` ORDER BY `create_time` DESC " +
-            "LIMIT #{limit} OFFSET #{offset}")
-    List<Long> findIdsToDelete(@Param("limit") int limit, @Param("offset") int offset);
+    // 查询所有出现过的 (graphspace, graph) 组合
+    @Select("SELECT `graphspace`, `graph` FROM `execute_history` " +
+            "GROUP BY `graphspace`, `graph`")
+    List<ExecuteHistory> findScopes();
+
+    @Select("SELECT COUNT(*) FROM `execute_history` " +
+            "WHERE `graphspace` = #{graphspace} AND `graph` = #{graph}")
+    int countScope(@Param("graphspace") String graphspace,
+                   @Param("graph") String graph);
+
+    @Select("SELECT `id` FROM `execute_history` " +
+            "WHERE `graphspace` = #{graphspace} AND `graph` = #{graph} " +
+            "ORDER BY `create_time` ASC, `id` ASC " +
+            "LIMIT #{batchSize}")
+    List<Long> findIdsOldestFirst(@Param("graphspace") String graphspace,
+                                  @Param("graph") String graph,
+                                  @Param("batchSize") int batchSize);
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
index 6361aed2c..d2eb78e8d 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
@@ -71,6 +71,7 @@ import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
@@ -454,7 +455,8 @@ public class GraphsService {
                                                String graphSpace,
                                                String graph) {
         GraphStatisticsEntity result;
-        this.graphStatistics.clear();
+        // Only invalidate the cache entry of the current graph
+        this.graphStatistics.remove(getStatisticsKey(graphSpace, graph));
         result = postSmallStatistics(client, graphSpace, graph);
 
         return result;
@@ -464,30 +466,39 @@ public class GraphsService {
                                HugeClient client,
                                String graphSpace,
                                String graph) {
-        if (isBigGraph(pdClient, graphSpace, graph)) {
-            GremlinQuery query = new GremlinQuery(GREMLIN_STATISTICS_VERTEX);
-            long vid = executeAsyncTask(client, graphSpace, graph, query);
+        if (!isBigGraph(pdClient, graphSpace, graph)) {
+            return;
+        }
+        String graphKey = getStatisticsKey(graphSpace, graph);
+        GremlinQuery query = new GremlinQuery(GREMLIN_STATISTICS_VERTEX);
+        long vid = executeAsyncTask(client, graphSpace, graph, query);
+        long eid;
+        try {
             query.setContent(GREMLIN_STATISTICS_EDGE);
-            long eid = executeAsyncTask(client, graphSpace, graph, query);
-            String idPair = String.valueOf(vid) + "-" + String.valueOf(eid);
-            String graphKey = getStatisticsKey(graphSpace, graph);
-            if (this.graphStatistics.containsKey(graphKey)) {
-                Map<String, Object> graphCache =
-                        this.graphStatistics.get(graphKey);
-                if (graphCache.get(RUNNING_TASKS) != null) {
-                    List<String> idPairs =
-                            HubbleUtil.uncheckedCast(graphCache.get(RUNNING_TASKS));
-                    idPairs.add(idPair);
-                    return;
-                }
+            eid = executeAsyncTask(client, graphSpace, graph, query);
+        } catch (RuntimeException e) {
+            try {
+                client.task().cancel(vid);
+            } catch (RuntimeException cleanup) {
+                log.warn("Failed to cancel partial statistics task {}: {}",
+                         vid, cleanup.getMessage());
             }
-            List<String> idPairs = new ArrayList<>();
-            idPairs.add(idPair);
-            Map<String, Object> graphCache = new HashMap<>(2);
-            graphCache.put(RUNNING_TASKS, idPairs);
-            graphCache.put(STATISTICS, GraphStatisticsEntity.emptyEntity());
-            this.graphStatistics.put(graphKey, graphCache);
+            throw e;
         }
+        String idPair = String.valueOf(vid) + "-" + String.valueOf(eid);
+        this.graphStatistics.compute(graphKey, (key, graphCache) -> {
+            if (graphCache == null) {
+                graphCache = new ConcurrentHashMap<>(2);
+                graphCache.put(RUNNING_TASKS,
+                               new CopyOnWriteArrayList<String>());
+                graphCache.put(STATISTICS,
+                               GraphStatisticsEntity.emptyEntity());
+            }
+            List<String> idPairs =
+                    HubbleUtil.uncheckedCast(graphCache.get(RUNNING_TASKS));
+            idPairs.add(idPair);
+            return graphCache;
+        });
     }
 
     public GraphStatisticsEntity getLastStatistics(HugeClient client,
@@ -526,6 +537,12 @@ public class GraphsService {
                 String[] idVE = idPair.split("-");
                 Task taskV = taskMap.get(idVE[0]);
                 Task taskE = taskMap.get(idVE[1]);
+                if (taskV == null || taskE == null) {
+                    // The server no longer knows these tasks. Drop the pair,
+                    // otherwise it is re-queried on every call and never drains.
+                    removeIds.add(idPair);
+                    continue;
+                }
                 boolean success = taskV.success() && taskE.success();
                 if (removable(taskV) || removable(taskE) || success) {
                     removeIds.add(idPair);
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/DatasourceService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/DatasourceService.java
index 62dc3a4f1..ac6d6627e 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/DatasourceService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/DatasourceService.java
@@ -25,6 +25,7 @@ import org.apache.hugegraph.entity.load.Datasource;
 import org.apache.hugegraph.exception.InternalException;
 import org.apache.hugegraph.mapper.load.DatasourceMapper;
 import org.apache.hugegraph.util.HubbleUtil;
+import org.apache.hugegraph.util.PageUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Isolation;
@@ -46,7 +47,8 @@ public class DatasourceService {
             wrapper.like("datasource_name", query);
         }
         wrapper.orderByDesc("create_time");
-        return this.mapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+        return this.mapper.selectPage(
+                new Page<>(pageNo, PageUtil.boundedSize(pageSize)), wrapper);
     }
 
     @Transactional(isolation = Isolation.READ_COMMITTED)
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/FileMappingService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/FileMappingService.java
index 06495a763..0e531df83 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/FileMappingService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/FileMappingService.java
@@ -27,8 +27,10 @@ import java.io.FileReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.security.SecureRandom;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -66,6 +68,7 @@ import org.apache.hugegraph.mapper.load.JobManagerMapper;
 import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.util.Ex;
 import org.apache.hugegraph.util.HubbleUtil;
+import org.apache.hugegraph.util.PageUtil;
 import org.apache.hugegraph.util.StringUtil;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -82,6 +85,11 @@ public class FileMappingService {
     public static final String JOB_PREIFX = "job-";
     public static final String FILE_PREIFX = "file-mapping-";
 
+    private static final String TOKEN_SUFFIX_CHARS =
+            "0123456789abcdefghijklmnopqrstuvwxyz";
+    private static final int TOKEN_SUFFIX_LENGTH = 4;
+    private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
+
     @Autowired
     private HugeConfig config;
     @Autowired
@@ -149,7 +157,8 @@ public class FileMappingService {
         query.eq("job_id", jobId);
         query.eq("file_status", FileMappingStatus.COMPLETED.getValue());
         query.orderByDesc("create_time");
-        Page<FileMapping> page = new Page<>(pageNo, pageSize);
+        Page<FileMapping> page = new Page<>(pageNo,
+                                            PageUtil.boundedSize(pageSize));
         return this.mapper.selectPage(page, query);
     }
 
@@ -174,9 +183,32 @@ public class FileMappingService {
         }
     }
 
+    @Transactional(isolation = Isolation.READ_COMMITTED)
+    public void detachFromJob(int mappingId, int jobId) {
+        if (this.mapper.detachFromJob(mappingId, jobId) != 1) {
+            throw new InternalException("entity.update.failed", mappingId);
+        }
+    }
+
     public String generateFileToken(String fileName) {
+        /*
+         * Append a short random suffix so that tokens cannot be guessed
+         * from the file name and upload time alone. Note that
+         * checkFileNameMatchToken() only checks the md5 prefix, so the
+         * token still passes that check.
+         */
         return this.fileTokenPrefix(fileName) +
-               HubbleUtil.nowTime().getEpochSecond();
+               HubbleUtil.nowTime().getEpochSecond() + "-" +
+               randomTokenSuffix();
+    }
+
+    private static String randomTokenSuffix() {
+        StringBuilder suffix = new StringBuilder(TOKEN_SUFFIX_LENGTH);
+        for (int i = 0; i < TOKEN_SUFFIX_LENGTH; i++) {
+            int index = TOKEN_RANDOM.nextInt(TOKEN_SUFFIX_CHARS.length());
+            suffix.append(TOKEN_SUFFIX_CHARS.charAt(index));
+        }
+        return suffix.toString();
     }
 
     public FileUploadResult uploadFile(MultipartFile srcFile, String fileName,
@@ -372,6 +404,35 @@ public class FileMappingService {
         return Paths.get(destPath, currFile.getName()).toString();
     }
 
+    public void restoreMovedUpload(String movedPath, String originalPath) {
+        File movedFile = this.requirePathUnderUploadRoot(movedPath);
+        File originalFile = this.requirePathUnderUploadRoot(originalPath);
+        if (!movedFile.exists()) {
+            if (originalFile.exists()) {
+                return;
+            }
+            throw new InternalException(
+                      "Failed to restore upload file after database rollback");
+        }
+        try {
+            originalFile = this.requirePathUnderUploadRoot(originalPath);
+            FileUtils.moveFile(movedFile, originalFile);
+        } catch (IOException e) {
+            throw new InternalException(
+                      "Failed to restore upload file after database rollback",
+                      e);
+        }
+
+        File movedDirectory = movedFile.getParentFile();
+        String[] children = movedDirectory == null ? null :
+                            movedDirectory.list();
+        if (children != null && children.length == 0 &&
+            !movedDirectory.delete()) {
+            log.warn("Failed to remove empty upload directory {}",
+                     movedDirectory);
+        }
+    }
+
     public void deleteDiskFile(FileMapping mapping) {
         File file = this.requirePathUnderUploadRoot(mapping.getPath());
         if (file.isDirectory()) {
@@ -403,7 +464,8 @@ public class FileMappingService {
     @Scheduled(fixedRate = 10 * 60 * 1000)
     public void deleteUnfinishedFile() {
         QueryWrapper<FileMapping> query = Wrappers.query();
-        query.in("file_status", FileMappingStatus.UPLOADING.getValue());
+        query.in("file_status", FileMappingStatus.UPLOADING.getValue(),
+                 FileMappingStatus.FAILURE.getValue());
         List<FileMapping> mappings = this.mapper.selectList(query);
         long threshold = this.config.get(
                          HubbleOptions.UPLOAD_FILE_MAX_TIME_CONSUMING) * 1000;
@@ -501,25 +563,38 @@ public class FileMappingService {
     }
 
     private Path normalizePath(File file) {
-        Path path = file.toPath();
+        Path path = file.toPath().toAbsolutePath().normalize();
         try {
             if (file.exists()) {
                 return path.toRealPath();
             }
+            Path existing = path.getParent();
+            while (existing != null && !Files.exists(existing)) {
+                existing = existing.getParent();
+            }
+            if (existing != null) {
+                Path resolvedParent = existing.toRealPath();
+                return resolvedParent.resolve(existing.relativize(path))
+                                     .normalize();
+            }
         } catch (IOException e) {
             throw new InternalException("Failed to resolve upload path '%s'",
                                         e, file);
         }
-        return path.toAbsolutePath().normalize();
+        return path;
     }
 
     private void tryDeleteUnfinishedMapping(FileMapping mapping) {
         String filePath = mapping.getPath();
         try {
-            FileUtils.forceDelete(this.requirePathUnderUploadRoot(filePath));
+            File file = this.requirePathUnderUploadRoot(filePath);
+            if (file.exists()) {
+                FileUtils.forceDelete(file);
+            }
         } catch (IOException e) {
             log.warn("Failed to delete expired uploading file {}",
                      filePath, e);
+            return;
         } catch (RuntimeException e) {
             log.warn("Skip deleting expired uploading file {} because the " +
                      "path is invalid", filePath, e);
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java
index b724c8e9f..ecbf666ed 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java
@@ -31,6 +31,7 @@ import lombok.extern.log4j.Log4j2;
 
 import org.apache.hugegraph.driver.HugeClient;
 import org.apache.hugegraph.entity.GraphConnection;
+import org.apache.hugegraph.entity.enums.FileMappingStatus;
 import org.apache.hugegraph.entity.enums.JobStatus;
 import org.apache.hugegraph.entity.enums.LoadStatus;
 import org.apache.hugegraph.entity.load.FileMapping;
@@ -40,6 +41,7 @@ import org.apache.hugegraph.exception.ExternalException;
 import org.apache.hugegraph.exception.InternalException;
 import org.apache.hugegraph.mapper.load.JobManagerMapper;
 import org.apache.hugegraph.util.HubbleUtil;
+import org.apache.hugegraph.util.PageUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Isolation;
@@ -100,7 +102,8 @@ public class JobManagerService {
             query.like("job_name", content);
         }
         query.orderByDesc("create_time");
-        Page<JobManager> page = new Page<>(pageNo, pageSize);
+        Page<JobManager> page = new Page<>(pageNo,
+                                           PageUtil.boundedSize(pageSize));
         IPage<JobManager> list = this.mapper.selectPage(page, query);
         list.getRecords().forEach(task -> {
             this.refreshStatus(task);
@@ -122,9 +125,8 @@ public class JobManagerService {
             query.like("job_name", content);
         }
         query.orderByDesc("create_time");
-        IPage<JobManager> list = this.mapper.selectPage(new Page<>(pageNo,
-                                                                   pageSize),
-                                                        query);
+        IPage<JobManager> list = this.mapper.selectPage(
+                new Page<>(pageNo, PageUtil.boundedSize(pageSize)), query);
         list.getRecords().forEach(this::refreshStatus);
         return list;
     }
@@ -180,6 +182,41 @@ public class JobManagerService {
         }
     }
 
+    @Transactional(isolation = Isolation.READ_COMMITTED)
+    public void completeUpload(FileMapping mapping) {
+        Integer jobId = mapping.getJobId();
+        if (jobId == null) {
+            throw new InternalException("File mapping has no job id");
+        }
+        this.fileMappingService.update(mapping);
+        if (this.mapper.increaseJobSize(jobId, mapping.getTotalSize()) != 1) {
+            throw new InternalException("entity.update.failed", jobId);
+        }
+    }
+
+    @Transactional(isolation = Isolation.READ_COMMITTED)
+    public void deleteMappings(int jobId, List<FileMapping> mappings) {
+        if (mappings.isEmpty()) {
+            return;
+        }
+        long releasedSize = 0L;
+        for (FileMapping mapping : mappings) {
+            if (mapping.getJobId() == null || mapping.getJobId() != jobId) {
+                throw new InternalException("File mapping does not belong to " +
+                                            "job %s", jobId);
+            }
+            if (mapping.getFileStatus() == FileMappingStatus.COMPLETED) {
+                releasedSize = Math.addExact(releasedSize,
+                                             mapping.getTotalSize());
+            }
+            this.fileMappingService.detachFromJob(mapping.getId(), jobId);
+        }
+        if (this.mapper.decreaseJobSize(jobId, releasedSize) != 1) {
+            throw new InternalException("entity.update.failed", jobId);
+        }
+        this.deleteDiskFilesAfterCommit(mappings);
+    }
+
     @Transactional(isolation = Isolation.READ_COMMITTED)
     public void remove(int id) {
         if (this.mapper.deleteById(id) != 1) {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java
index a07a09bd2..32aef50da 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java
@@ -59,6 +59,7 @@ import org.apache.hugegraph.service.schema.EdgeLabelService;
 import org.apache.hugegraph.service.schema.VertexLabelService;
 import org.apache.hugegraph.util.Ex;
 import org.apache.hugegraph.util.HubbleUtil;
+import org.apache.hugegraph.util.PageUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -76,6 +77,8 @@ import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -88,6 +91,10 @@ import org.apache.commons.lang3.StringUtils;
 @Service
 public class LoadTaskService {
 
+    public static final String NO_ERROR_FILE_REASON =
+            "For some reason, the error file was not generated. " +
+            "Please check the log for details";
+
     @Autowired
     private LoadTaskMapper mapper;
     @Autowired
@@ -134,7 +141,8 @@ public class LoadTaskService {
         query.eq("graph", graph);
         query.eq("job_id", jobId);
         query.orderByDesc("create_time");
-        Page<LoadTask> page = new Page<>(pageNo, pageSize);
+        Page<LoadTask> page = new Page<>(pageNo,
+                                         PageUtil.boundedSize(pageSize));
         return this.mapper.selectPage(page, query);
     }
 
@@ -302,13 +310,24 @@ public class LoadTaskService {
     }
 
     public LoadTask pause(int taskId) {
+        /*
+         * The in-memory container is not repopulated on startup, so a task
+         * that was RUNNING before a restart is only visible in the database
+         */
         LoadTask task = this.runningTaskContainer.get(taskId);
+        boolean loading = task != null;
+        if (!loading) {
+            task = this.get(taskId);
+        }
+        Ex.check(task != null, "load.task.not-exist.id", taskId);
         Ex.check(task.getStatus() == LoadStatus.RUNNING,
                  "Can only pause the RUNNING task");
         // Mark status as paused, should set before context.stopLoading()
         task.setStatus(LoadStatus.PAUSED);
-        // Let HugeGraphLoader stop
-        task.stop();
+        if (loading) {
+            // Let HugeGraphLoader stop
+            task.stop();
+        }
 
         task.lock();
         try {
@@ -342,15 +361,22 @@ public class LoadTaskService {
 
     public LoadTask stop(int taskId) {
         LoadTask task = this.runningTaskContainer.get(taskId);
-        if (task == null) {
+        boolean loading = task != null;
+        if (!loading) {
             task = this.get(taskId);
         }
+        Ex.check(task != null, "load.task.not-exist.id", taskId);
         LoadStatus status = task.getStatus();
         Ex.check(status == LoadStatus.RUNNING || status == LoadStatus.PAUSED,
                  "Can only stop the RUNNING or PAUSED task");
         // Mark status as stopped
         task.setStatus(LoadStatus.STOPPED);
-        if (status == LoadStatus.RUNNING) {
+        /*
+         * Only a task taken from the in-memory container owns a loader, one
+         * restored from the database has nothing running to interrupt, so
+         * just persist the terminal status for it
+         */
+        if (loading && status == LoadStatus.RUNNING) {
             task.stop();
         }
 
@@ -387,6 +413,9 @@ public class LoadTaskService {
     }
 
     public String readLoadFailedReason(FileMapping mapping) {
+        if (mapping == null) {
+            return NO_ERROR_FILE_REASON;
+        }
         String path = mapping.getPath();
         File parentDir = FileUtils.getFile(path).getParentFile();
         File failureDataDir = FileUtils.getFile(parentDir, "mapping",
@@ -395,21 +424,28 @@ public class LoadTaskService {
         File[] errorFiles = failureDataDir.listFiles((dir, name) -> {
             return name.endsWith("error");
         });
-        if (errorFiles == null) {
-            return "For some reason, the error file was not generated. " +
-                   "Please check the log for details";
-        }
-        Ex.check(errorFiles.length == 1,
-                 "There should exist only one error file, actual is %s",
-                 errorFiles.length);
-        File errorFile = errorFiles[0];
-        try {
-            return FileUtils.readFileToString(errorFile,
-                                              Charset.defaultCharset());
-        } catch (IOException e) {
-            throw new InternalException("Failed to read error file %s",
-                                        e, errorFile);
+        if (errorFiles == null || errorFiles.length == 0) {
+            return NO_ERROR_FILE_REASON;
+        }
+        /*
+         * A single run can legitimately produce both a parse error file and
+         * an insert error file, report the contents of all of them
+         */
+        Arrays.sort(errorFiles, Comparator.comparing(File::getName));
+        StringBuilder reason = new StringBuilder();
+        for (File errorFile : errorFiles) {
+            if (reason.length() > 0) {
+                reason.append(System.lineSeparator());
+            }
+            try {
+                reason.append(FileUtils.readFileToString(
+                              errorFile, Charset.defaultCharset()));
+            } catch (IOException e) {
+                throw new InternalException("Failed to read error file %s",
+                                            e, errorFile);
+            }
         }
+        return reason.toString();
     }
 
     public void pauseAllTasks() {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/ESService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/ESService.java
index fe50678a6..b2004cb81 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/ESService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/ESService.java
@@ -44,8 +44,11 @@ public abstract class ESService {
     @Autowired
     private HugeConfig config;
 
-    public static final String[] LEVELS = new String[]{"TRACE", "OFF",
-        "FATAL", "ERROR", "WARN", "INFO", "DEBUG"};
+    // Ordered from the most severe to the least severe, LogService filters
+    // by Arrays.copyOfRange(LEVELS, 0, index + 1), so the prefix of this
+    // array must be exactly the levels at least as severe as the selected one
+    public static final String[] LEVELS = new String[]{"OFF", "FATAL",
+        "ERROR", "WARN", "INFO", "DEBUG", "TRACE"};
 
     public static volatile ElasticsearchClient elasticsearchClient;
 
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LogService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LogService.java
index 15476a679..6c481d5c3 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LogService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LogService.java
@@ -146,8 +146,10 @@ public class LogService extends ESService {
                         HubbleUtil.uncheckedCast(hit.source())));
             }
 
-            int resultCount = (int) (search.hits().total().value());
-            if (resultCount < batchSize) {
+            // Break on the size of the returned batch, not on the total
+            // number of matches: total() may be null and is unrelated to
+            // whether another page exists
+            if (search.hits().hits().size() < batchSize) {
                 break;
             }
         }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/EditElementHistoryService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/EditElementHistoryService.java
index 34c5a9fc6..addbc5814 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/EditElementHistoryService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/EditElementHistoryService.java
@@ -43,8 +43,9 @@ public class EditElementHistoryService {
     @Autowired
     private EditElementHistoryMapper mapper;
 
-    public List<ElementEditHistory> queryByLimit(int limit) {
-        return mapper.queryByLimit(limit);
+    public List<ElementEditHistory> queryByLimit(String graphspace,
+                                                 String graph, int limit) {
+        return mapper.queryByLimit(graphspace, graph, limit);
     }
 
     public int add(ElementEditHistory history) {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java
index 5239967f6..a56e85188 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java
@@ -40,6 +40,7 @@ import org.apache.hugegraph.mapper.query.ExecuteHistoryMapper;
 import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.structure.Task;
 import org.apache.hugegraph.util.HubbleUtil;
+import org.apache.hugegraph.util.PageUtil;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -77,7 +78,8 @@ public class ExecuteHistoryService {
         if (text2Gremlin) {
             query.apply("LENGTH(text) > 0");
         }
-        Page<ExecuteHistory> page = new Page<>(current, pageSize);
+        Page<ExecuteHistory> page = new Page<>(current,
+                                               PageUtil.boundedSize(pageSize));
         IPage<ExecuteHistory> results = this.mapper.selectPage(page, query);
 
         int limit = this.config.get(HubbleOptions.EXECUTE_HISTORY_SHOW_LIMIT);
@@ -169,8 +171,9 @@ public class ExecuteHistoryService {
 
     @Async
     @Scheduled(fixedDelay = 24 * 60 * 60 * 1000)
-    @Transactional(isolation = Isolation.READ_COMMITTED)
     public void removeExceedLimit() {
+        // Do not wrap the complete cleanup in one transaction. Without an
+        // outer transaction, each bounded mapper DELETE commits independently.
         int limit = this.config.get(HubbleOptions.EXECUTE_HISTORY_SHOW_LIMIT);
         this.mapper.deleteExceedLimit(limit);
     }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java
index 66afe73ac..963c77e5e 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java
@@ -79,7 +79,7 @@ public class GremlinCollectionService {
                 // order by relativity
                 checkSingleOrder(nameOrderAsc, timeOrderAsc);
                 return this.mapper.selectByContentInPage(page, graphSpace, graph,
-                                                         content, type);
+                                                         value, type);
             }
         } else {
             // Select all
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java
index 67ae3fb41..0def0ab4a 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java
@@ -66,6 +66,7 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiConsumer;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -256,13 +257,8 @@ public class QueryService {
     private String optimize(String content) {
         // Optimize each gremlin statemnts
         int limit = this.config.get(HubbleOptions.GREMLIN_SUFFIX_LIMIT);
-        String[] originalParts = StringUtils.split(content, ";");
-        String[] optimizeParts = new String[originalParts.length];
-        for (int i = 0; i < originalParts.length; i++) {
-            String part = originalParts[i];
-            optimizeParts[i] = GremlinUtil.optimizeLimit(part, limit);
-        }
-        return StringUtils.join(optimizeParts, ";");
+        // The statement splitting is quote-aware, see GremlinUtil
+        return GremlinUtil.optimizeLimit(content, limit);
     }
 
     private ResultSet executeGremlin(String gremlin, HugeClient client) {
@@ -341,6 +337,20 @@ public class QueryService {
                 continue;
             }
             Object object = result.getObject();
+            if (object instanceof Map) {
+                List<Object> elements =
+                        unwrapCypherElements((Map<?, ?>) object);
+                if (elements != null) {
+                    for (Object element : elements) {
+                        Type elementType = element instanceof Vertex ?
+                                           Type.VERTEX : Type.EDGE;
+                        typeVotes.compute(elementType,
+                                          (k, v) -> v == null ? 1 : v + 1);
+                        typedData.add(element);
+                    }
+                    continue;
+                }
+            }
             Type type;
             if (object instanceof Vertex) {
                 type = Type.VERTEX;
@@ -427,6 +437,61 @@ public class QueryService {
         }
     }
 
+    /**
+     * Cypher rows arrive as a map keyed by the RETURN variables whose values
+     * are _type-tagged node/relationship maps. Unwrap them into detached
+     * Vertex/Edge instances so such results can render as a graph; return
+     * null when the row is not purely graph elements.
+     */
+    private static List<Object> unwrapCypherElements(Map<?, ?> row) {
+        if (row.isEmpty()) {
+            return null;
+        }
+        List<Object> elements = new ArrayList<>(row.size());
+        for (Object value : row.values()) {
+            Object element = toGraphElement(value);
+            if (element == null) {
+                return null;
+            }
+            elements.add(element);
+        }
+        return elements;
+    }
+
+    private static Object toGraphElement(Object value) {
+        if (!(value instanceof Map)) {
+            return null;
+        }
+        Map<?, ?> map = (Map<?, ?>) value;
+        Object type = map.get("_type");
+        if ("node".equals(type)) {
+            Vertex vertex = new Vertex(String.valueOf(map.get("_label")));
+            copyElementProperties(map, vertex::property);
+            vertex.id(map.get("_id"));
+            return vertex;
+        }
+        if ("relationship".equals(type)) {
+            Edge edge = new Edge(String.valueOf(map.get("_label")));
+            edge.id(String.valueOf(map.get("_id")));
+            edge.sourceId(map.get("_outV"));
+            edge.targetId(map.get("_inV"));
+            copyElementProperties(map, edge::property);
+            return edge;
+        }
+        return null;
+    }
+
+    private static void copyElementProperties(Map<?, ?> map,
+                                              BiConsumer<String, Object> setter) {
+        for (Map.Entry<?, ?> entry : map.entrySet()) {
+            String key = String.valueOf(entry.getKey());
+            if (key.startsWith("_") || entry.getValue() == null) {
+                continue;
+            }
+            setter.accept(key, entry.getValue());
+        }
+    }
+
     private GraphView buildGraphView(TypedResult result, HugeClient client) {
         List<Object> data = result.getData();
         if (!result.getType().isGraph() || CollectionUtils.isEmpty(data)) {
@@ -458,20 +523,28 @@ public class QueryService {
             }
         }
 
-        if (!edges.isEmpty()) {
-            if (vertices.isEmpty()) {
-                vertices = this.verticesOfEdge(result, edges, client);
+        boolean enrichmentSucceeded = true;
+        try {
+            if (!edges.isEmpty()) {
+                if (vertices.isEmpty()) {
+                    vertices = this.verticesOfEdge(result, edges, client);
+                } else {
+                    // TODO: reduce the number of requests
+                    vertices.putAll(this.verticesOfEdge(result, edges, client));
+                }
             } else {
-                // TODO: reduce the number of requests
-                vertices.putAll(this.verticesOfEdge(result, edges, client));
-            }
-        } else {
-            if (!vertices.isEmpty()) {
-                edges = this.edgesOfVertex(result, vertices, client);
+                if (!vertices.isEmpty()) {
+                    edges = this.edgesOfVertex(result, vertices, client);
+                }
             }
+        } catch (Exception e) {
+            // The linked-element lookup is best-effort decoration; keep the
+            // elements already returned when it fails (e.g. gremlin rejected)
+            log.warn("Failed to enrich graph view with linked elements", e);
+            enrichmentSucceeded = false;
         }
 
-        if (!edges.isEmpty()) {
+        if (!edges.isEmpty() && enrichmentSucceeded) {
             Ex.check(!vertices.isEmpty(),
                      "gremlin.edges.linked-vertex.not-exist");
         }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/GremlinUtil.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/GremlinUtil.java
index 55d21d837..c51504abf 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/GremlinUtil.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/GremlinUtil.java
@@ -18,9 +18,7 @@
 
 package org.apache.hugegraph.util;
 
-import java.util.ArrayList;
 import java.util.LinkedHashSet;
-import java.util.List;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -312,31 +310,181 @@ public final class GremlinUtil {
     public static String optimizeLimit(String gremlin, int limit) {
         // FIXME: Do not treat suffix matching as a resource-safety boundary. Decide on
         // server-side timeout/result/byte limits without changing aggregation semantics.
-        String[] rawLines = StringUtils.split(gremlin, "\n");
-        List<String> newLines = new ArrayList<>(rawLines.length);
-        for (String rawLine : rawLines) {
-            boolean ignored = IGNORED_PATTERNS.stream().anyMatch(pattern -> {
-                return pattern.matcher(rawLine).find();
-            });
-            if (ignored) {
-                newLines.add(rawLine);
+        /*
+         * Scan the script with quote awareness: track single-quote,
+         * double-quote and backslash-escape state so that ';' and true
+         * statement-ending newlines are treated as separators only when
+         * outside quotes.
+         * The suffix-pattern matching and '.limit(N)' appending are
+         * applied only to true statement tails (outside quotes).
+         */
+        StringBuilder result = new StringBuilder(gremlin.length());
+        StringBuilder statement = new StringBuilder();
+        StringBuilder matchable = new StringBuilder();
+        boolean inSingleQuote = false;
+        boolean inDoubleQuote = false;
+        boolean inLineComment = false;
+        boolean inBlockComment = false;
+        boolean escaped = false;
+        for (int i = 0; i < gremlin.length(); i++) {
+            char ch = gremlin.charAt(i);
+            if (escaped) {
+                statement.append(ch);
+                matchable.append(ch);
+                escaped = false;
                 continue;
             }
-            boolean matched = false;
-            for (Pattern pattern : LIMIT_PATTERNS) {
-                Matcher matcher = pattern.matcher(rawLine);
-                if (matcher.find()) {
-                    matched = true;
-                    break;
+            if (inLineComment) {
+                if (ch == '\n') {
+                    if (chainContinues(gremlin, i)) {
+                        statement.append(ch);
+                        matchable.append(ch);
+                    } else {
+                        result.append(optimizeStatement(statement.toString(),
+                                                        matchable.toString(),
+                                                        limit));
+                        result.append(ch);
+                        statement.setLength(0);
+                        matchable.setLength(0);
+                    }
+                    inLineComment = false;
+                } else {
+                    statement.append(ch);
+                    matchable.append(' ');
                 }
+                continue;
+            }
+            if (inBlockComment) {
+                statement.append(ch);
+                matchable.append(' ');
+                if (ch == '*' && i + 1 < gremlin.length() &&
+                    gremlin.charAt(i + 1) == '/') {
+                    statement.append('/');
+                    matchable.append(' ');
+                    i++;
+                    inBlockComment = false;
+                }
+                continue;
+            }
+            if (!inSingleQuote && !inDoubleQuote &&
+                (gremlin.startsWith("'''", i) ||
+                 gremlin.startsWith("\"\"\"", i))) {
+                return gremlin;
+            }
+            if (!inSingleQuote && !inDoubleQuote && ch == '$' &&
+                i + 1 < gremlin.length() &&
+                gremlin.charAt(i + 1) == '/') {
+                return gremlin;
+            }
+            if (!inSingleQuote && !inDoubleQuote && ch == '/' &&
+                i + 1 < gremlin.length()) {
+                char next = gremlin.charAt(i + 1);
+                if (next == '/' || next == '*') {
+                    statement.append(ch).append(next);
+                    matchable.append("  ");
+                    i++;
+                    inLineComment = next == '/';
+                    inBlockComment = next == '*';
+                    continue;
+                }
+                // This may be a slashy literal or division. Both need a real
+                // Groovy lexer to distinguish safely.
+                return gremlin;
+            }
+            if ((inSingleQuote || inDoubleQuote) && ch == '\\') {
+                statement.append(ch);
+                matchable.append(ch);
+                escaped = true;
+                continue;
+            }
+            if (ch == '\'' && !inDoubleQuote) {
+                inSingleQuote = !inSingleQuote;
+                statement.append(ch);
+                matchable.append(ch);
+                continue;
+            }
+            if (ch == '"' && !inSingleQuote) {
+                inDoubleQuote = !inDoubleQuote;
+                statement.append(ch);
+                matchable.append(ch);
+                continue;
+            }
+            boolean separator = ch == ';' ||
+                                ch == '\n' &&
+                                !chainContinues(gremlin, i);
+            if (separator && !inSingleQuote && !inDoubleQuote) {
+                result.append(optimizeStatement(statement.toString(),
+                                                matchable.toString(), limit));
+                result.append(ch);
+                statement.setLength(0);
+                matchable.setLength(0);
+                continue;
+            }
+            statement.append(ch);
+            matchable.append(ch);
+        }
+        if (inSingleQuote || inDoubleQuote || inBlockComment) {
+            // Unclosed quote: never append '.limit(N)' inside a string
+            result.append(statement);
+        } else {
+            result.append(optimizeStatement(statement.toString(),
+                                            matchable.toString(), limit));
+        }
+        return result.toString();
+    }
+
+    private static boolean chainContinues(String gremlin, int newline) {
+        for (int i = newline + 1; i < gremlin.length(); i++) {
+            char next = gremlin.charAt(i);
+            if (Character.isWhitespace(next)) {
+                continue;
             }
-            if (matched) {
-                newLines.add(rawLine + ".limit(" + limit + ")");
-            } else {
-                newLines.add(rawLine);
+            if (next == '/' && i + 1 < gremlin.length()) {
+                char afterSlash = gremlin.charAt(i + 1);
+                if (afterSlash == '/') {
+                    int lineEnd = gremlin.indexOf('\n', i + 2);
+                    if (lineEnd < 0) {
+                        return false;
+                    }
+                    i = lineEnd;
+                    continue;
+                }
+                if (afterSlash == '*') {
+                    int commentEnd = gremlin.indexOf("*/", i + 2);
+                    if (commentEnd < 0) {
+                        return false;
+                    }
+                    i = commentEnd + 1;
+                    continue;
+                }
+            }
+            return next == '.';
+        }
+        return false;
+    }
+
+    private static String optimizeStatement(String statement,
+                                            String matchable, int limit) {
+        int codeEnd = matchable.length();
+        while (codeEnd > 0 && Character.isWhitespace(
+                                  matchable.charAt(codeEnd - 1))) {
+            codeEnd--;
+        }
+        String code = matchable.substring(0, codeEnd);
+        boolean ignored = IGNORED_PATTERNS.stream().anyMatch(pattern -> {
+            return pattern.matcher(code).find();
+        });
+        if (ignored) {
+            return statement;
+        }
+        for (Pattern pattern : LIMIT_PATTERNS) {
+            Matcher matcher = pattern.matcher(code);
+            if (matcher.find()) {
+                return statement.substring(0, codeEnd) + ".limit(" + limit +
+                       ")" + statement.substring(codeEnd);
             }
         }
-        return StringUtils.join(newLines, "\n");
+        return statement;
     }
 
     private static Set<Pattern> compile(Set<String> texts) {
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/PageUtil.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/PageUtil.java
index 523410fe6..4dbb449ef 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/PageUtil.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/util/PageUtil.java
@@ -29,6 +29,30 @@ public final class PageUtil {
 
     public static final int MAX_PAGE_SIZE = 500;
 
+    /*
+     * Hard cap used instead of an unbounded query when the frontend
+     * passes page_size=-1 (meaning "all records")
+     */
+    public static final int HARD_CAP = 10000;
+
+    /**
+     * Bound the requested page size: page_size=-1 is accepted from the
+     * frontend but is turned into a hard cap instead of unbounded.
+     */
+    public static int boundedSize(int requested) {
+        if (requested < -1) {
+            throw new IllegalArgumentException("page_size must be >= -1");
+        }
+        return requested == -1 ? HARD_CAP : requested;
+    }
+
+    public static long boundedSize(long requested) {
+        if (requested < -1L) {
+            throw new IllegalArgumentException("page_size must be >= -1");
+        }
+        return requested == -1L ? HARD_CAP : requested;
+    }
+
     public static void checkPage(int pageNo, int pageSize) {
         boolean invalidSize = pageSize != -1 &&
                               (pageSize < 1 || pageSize > MAX_PAGE_SIZE);
diff --git a/hugegraph-hubble/hubble-be/src/main/resources/database/schema.sql b/hugegraph-hubble/hubble-be/src/main/resources/database/schema.sql
index fbfbac687..df804edd0 100644
--- a/hugegraph-hubble/hubble-be/src/main/resources/database/schema.sql
+++ b/hugegraph-hubble/hubble-be/src/main/resources/database/schema.sql
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS `execute_history` (
     );
 
 CREATE INDEX IF NOT EXISTS `execute_history_conn_id` ON `execute_history`(`conn_id`);
+CREATE INDEX IF NOT EXISTS `execute_history_graph_create_time` ON `execute_history`(`graphspace`, `graph`, `create_time`);
 
 
 // DROP TABLE IF EXISTS `edit_history`;
@@ -120,7 +121,7 @@ CREATE TABLE IF NOT EXISTS `gremlin_collection` (
     `content` VARCHAR(65535) NOT NULL,
     `create_time` DATETIME(6) NOT NULL,
     PRIMARY KEY (`id`),
-    UNIQUE (`conn_id`, `name`)
+    UNIQUE (`graphspace`, `graph`, `name`, `type`)
 );
 CREATE INDEX IF NOT EXISTS `gremlin_collection_conn_id` ON `gremlin_collection`(`conn_id`);
 
@@ -145,6 +146,7 @@ CREATE TABLE IF NOT EXISTS `file_mapping` (
     UNIQUE (`conn_id`, `job_id`, `name`)
 );
 CREATE INDEX IF NOT EXISTS `file_mapping_conn_id` ON `file_mapping`(`conn_id`);
+CREATE INDEX IF NOT EXISTS `file_mapping_job_id` ON `file_mapping`(`job_id`);
 
 CREATE TABLE IF NOT EXISTS `load_task` (
     `id` INT NOT NULL AUTO_INCREMENT,
@@ -201,6 +203,9 @@ CREATE TABLE IF NOT EXISTS `async_task` (
 
 CREATE INDEX IF NOT EXISTS `load_task_conn_id` ON `load_task`(`conn_id`);
 CREATE INDEX IF NOT EXISTS `load_task_file_id` ON `load_task`(`file_id`);
+CREATE INDEX IF NOT EXISTS `load_task_job_id` ON `load_task`(`job_id`);
+CREATE INDEX IF NOT EXISTS `job_manager_graph_create_time` ON `job_manager`(`graphspace`, `graph`, `create_time`);
+CREATE INDEX IF NOT EXISTS `async_task_graph` ON `async_task`(`graphspace`, `graph`);
 
 CREATE TABLE IF NOT EXISTS `datasource` (
     `id` INT NOT NULL AUTO_INCREMENT,
diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
index 3986e567e..e4b96eb0c 100644
--- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
+++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
@@ -181,6 +181,23 @@ load.file-mapping.load-parameter.max-insert-error.illegal=The load parameter 'ma
 load.file-mapping.load-parameter.insert-timeout.illegal=The load parameter 'insert-timeout' is illegal
 load.file-mapping.load-parameter.retry-times.illegal=The load parameter 'retry-times' is illegal
 load.file-mapping.load-parameter.retry-interval.illegal=The load parameter 'retry-interval' is illegal
+load.upload.file.no-permission=Cannot upload files because the current job status does not allow it
+load.upload.merge-file.failed=Failed to merge the uploaded file parts
+load.upload.move-file.failed=Failed to move the uploaded file
+load.upload.rename-file.failed=Failed to rename the uploaded file
+load.upload.delete-temp-dir.failed=Failed to delete the temporary upload directory
+deleted.file.no-permission=Cannot delete the file because the current job status does not allow it
+file-mapping.not-exist.id=The file does not exist with id {0}
+load.file-mapping.delete.task-in-running=Cannot delete the file with id {0} because an associated load task is still running, please stop the task first
+load.task.not-exist.id=The load task does not exist with id {0}
+load.task.reached-limit=The number of load tasks has reached the limit {0}
+load.task.create.no-permission=Cannot create the load task because the current job status does not allow it
+load.task.start.no-permission=Cannot start the load task because the current job status does not allow it
+load.task.pause.no-permission=Cannot pause the load task because the current job status does not allow it
+load.task.resume.no-permission=Cannot resume the load task because the current job status does not allow it
+load.task.stop.no-permission=Cannot stop the load task because the current job status does not allow it
+load.task.retry.no-permission=Cannot retry the load task because the current job status does not allow it
+load.task.delete.task-in-running=Cannot delete the load task with id {0} because it is still running, please stop it first
 load.build-task.failed=Build load task failed
 
 # job manager
@@ -190,6 +207,12 @@ job.manager.job-name.unmatch-regex=Invalid job name, valid name is up to 48 alph
 job.manager.job-remarks.reached-limit=Can't add job remarks because has length limit
 job.manager.job-remarks.unmatch-regex=Invalid job name, valid name is up to 200 alpha-numeric characters and underscores
 job.manager.status.unexpected=Job status doesn't meet expectations, it should be {0}, actually {1}
+job.manager.not-exist.id=The job does not exist with id {0}
+job-manager.not-exist.id=The job does not exist with id {0}
+job.manager.reached-limit=The number of jobs has reached the limit {0}
+
+# Async task
+async.task.not-exist.id=The async task does not exist with id {0}
 
 # Internal Exceptions
 common.unknown.enum.type=Unknown type {0} for enum {1}
@@ -228,4 +251,4 @@ service.url.parse.error=Parse host info ({0}) error?please check the url in \
   service config
 
 service.manual.disable.modify=Disallown modify manual service
-auth.login.throttled=Too many login failures. Please retry in {0} seconds
+auth.login.throttled=Too many login failures. Please retry in {0}s
diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
index e412e5f1f..beb88bbe0 100644
--- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
+++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
@@ -181,6 +181,23 @@ load.file-mapping.load-parameter.max-insert-error.illegal=导入参数 允许最
 load.file-mapping.load-parameter.insert-timeout.illegal=导入参数 插入超时时间/s 不合法
 load.file-mapping.load-parameter.retry-times.illegal=导入参数 插入失败重试次数 不合法
 load.file-mapping.load-parameter.retry-interval.illegal=导入参数 插入失败重试间隔/s 不合法
+load.upload.file.no-permission=当前导入任务的状态不允许上传文件
+load.upload.merge-file.failed=合并上传的文件分片失败
+load.upload.move-file.failed=移动上传的文件失败
+load.upload.rename-file.failed=重命名上传的文件失败
+load.upload.delete-temp-dir.failed=删除上传的临时目录失败
+deleted.file.no-permission=当前导入任务的状态不允许删除文件
+file-mapping.not-exist.id=不存在 id 为 {0} 的文件
+load.file-mapping.delete.task-in-running=不能删除 id 为 {0} 的文件,其关联的导入任务正在运行,请先停止该任务
+load.task.not-exist.id=不存在 id 为 {0} 的导入任务
+load.task.reached-limit=导入任务的数量已达到上限 {0}
+load.task.create.no-permission=当前导入任务的状态不允许创建导入任务
+load.task.start.no-permission=当前导入任务的状态不允许开始导入
+load.task.pause.no-permission=当前导入任务的状态不允许暂停导入
+load.task.resume.no-permission=当前导入任务的状态不允许恢复导入
+load.task.stop.no-permission=当前导入任务的状态不允许停止导入
+load.task.retry.no-permission=当前导入任务的状态不允许重试导入
+load.task.delete.task-in-running=不能删除 id 为 {0} 的导入任务,它正在运行,请先停止该任务
 load.build-task.failed=构建导入任务失败
 
 # job manager
@@ -190,6 +207,12 @@ job.manager.job-name.unmatch-regex=任务名不合法,任务名允许字母、
 job.manager.job-remarks.reached-limit=任务描述长度限制48个字符
 job.manager.job-remarks.unmatch-regex=任务描述不合法,任务描述允许字母、数字、中文、下划线,最多 200 个字符
 job.manager.status.unexpected=当前的任务状态不符合预期,应该是 {0},实际是 {1}
+job.manager.not-exist.id=不存在 id 为 {0} 的导入任务
+job-manager.not-exist.id=不存在 id 为 {0} 的导入任务
+job.manager.reached-limit=导入任务的数量已达到上限 {0}
+
+# Async task
+async.task.not-exist.id=不存在 id 为 {0} 的异步任务
 
 # Internal Exceptions
 common.unknown.enum.type=枚举 {1} 的值 {0} 未知
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/config/LegacySchemaStartupTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/config/LegacySchemaStartupTest.java
new file mode 100644
index 000000000..194286ff4
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/config/LegacySchemaStartupTest.java
@@ -0,0 +1,233 @@
+/*
+ *
+ * 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.hugegraph.config;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import javax.sql.DataSource;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.jdbc.datasource.init.ScriptUtils;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.entity.enums.ExecuteType;
+import org.apache.hugegraph.entity.query.ExecuteHistory;
+import org.apache.hugegraph.entity.query.GremlinCollection;
+import org.apache.hugegraph.options.HubbleOptions;
+import org.apache.hugegraph.service.query.ExecuteHistoryService;
+import org.apache.hugegraph.service.query.GremlinCollectionService;
+import org.apache.hugegraph.testutil.Assert;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = LegacySchemaStartupTest.TestConfiguration.class,
+                webEnvironment = SpringBootTest.WebEnvironment.NONE,
+                properties = {
+                    "spring.datasource.url=" + LegacySchemaStartupTest.JDBC_URL,
+                    "spring.datasource.driver-class-name=org.h2.Driver",
+                    "spring.datasource.username=sa",
+                    "spring.datasource.password=",
+                    "spring.datasource.schema=" +
+                    "file:src/main/resources/database/schema.sql",
+                    "spring.datasource.initialization-mode=always",
+                    "spring.datasource.hikari.maximum-pool-size=2",
+                    "spring.autoconfigure.exclude=" +
+                    "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration",
+                    "mybatis.configuration.map-underscore-to-camel-case=true",
+                    "mybatis.configuration.use-generated-keys=true",
+                    "mybatis-plus.type-enums-package=" +
+                    "org.apache.hugegraph.entity.enums"
+                })
+public class LegacySchemaStartupTest {
+
+    static final String JDBC_URL =
+            "jdbc:h2:./target/legacy-schema-startup;DB_CLOSE_DELAY=-1";
+
+    static {
+        prepareLegacyDatabase();
+    }
+
+    @Autowired
+    private DataSource dataSource;
+    @Autowired
+    private DatabaseSchemaMigrator migrator;
+    @Autowired
+    private ExecuteHistoryService historyService;
+    @Autowired
+    private GremlinCollectionService collectionService;
+    @MockBean
+    private HugeConfig config;
+
+    private HugeClient client;
+
+    @Before
+    public void setup() throws Exception {
+        Mockito.when(this.config.get(HubbleOptions.EXECUTE_HISTORY_SHOW_LIMIT))
+               .thenReturn(500);
+        this.client = Mockito.mock(HugeClient.class);
+        Mockito.when(this.client.getGraphSpaceName()).thenReturn("DEFAULT");
+        Mockito.when(this.client.getGraphName()).thenReturn("legacygraph");
+        try (Connection conn = this.dataSource.getConnection()) {
+            this.migrator.migrate(conn);
+        }
+    }
+
+    @Test
+    public void testLegacyDatabaseStartsAndRowsAreVisibleThroughServices()
+            throws Exception {
+        IPage<ExecuteHistory> histories = this.historyService.list(
+                this.client, ExecuteType.GREMLIN.getValue(), 1L, 10L, false);
+        Assert.assertEquals(1L, histories.getTotal());
+        Assert.assertEquals("g.V()", histories.getRecords().get(0).getContent());
+
+        IPage<GremlinCollection> collections = this.collectionService.list(
+                this.client, null, "GREMLIN", null, false, 1L, 10L);
+        Assert.assertEquals(1L, collections.getTotal());
+        Assert.assertEquals("saved", collections.getRecords().get(0).getName());
+
+        try (Connection conn = this.dataSource.getConnection()) {
+            this.assertScopedRows(conn);
+        }
+    }
+
+    @Test
+    public void testSavedQueryConstraintMatchesApplicationScope()
+            throws Exception {
+        try (Connection conn = this.dataSource.getConnection();
+             Statement statement = conn.createStatement()) {
+            statement.execute("DELETE FROM `gremlin_collection` " +
+                              "WHERE `graph` = 'othergraph'");
+            statement.execute("INSERT INTO `gremlin_collection` " +
+                              "(`conn_id`, `graphspace`, `graph`, `name`, " +
+                              "`type`, `content`, `create_time`) VALUES " +
+                              "(1, 'DEFAULT', 'othergraph', 'saved', " +
+                              "'GREMLIN', 'g.E()', CURRENT_TIMESTAMP)");
+            try (ResultSet rows = statement.executeQuery(
+                    "SELECT COUNT(*) FROM `gremlin_collection` WHERE " +
+                    "`graphspace` = 'DEFAULT' AND `graph` = 'othergraph' " +
+                    "AND `name` = 'saved' AND `type` = 'GREMLIN'")) {
+                Assert.assertTrue(rows.next());
+                Assert.assertEquals(1L, rows.getLong(1));
+            }
+            Assert.assertThrows(SQLException.class, () ->
+                    statement.execute("INSERT INTO `gremlin_collection` " +
+                                      "(`conn_id`, `graphspace`, `graph`, " +
+                                      "`name`, `type`, `content`, " +
+                                      "`create_time`) VALUES " +
+                                      "(1, 'DEFAULT', 'legacygraph', " +
+                                      "'saved', 'GREMLIN', 'g.E()', " +
+                                      "CURRENT_TIMESTAMP)"));
+        }
+    }
+
+    @Test
+    public void testLegacyScopeBackfillRequiresAllTargetColumns()
+            throws Exception {
+        try (Connection conn = this.dataSource.getConnection();
+             Statement statement = conn.createStatement()) {
+            statement.execute("DROP TABLE IF EXISTS `scope_guard`");
+            statement.execute("CREATE TABLE `scope_guard` (`conn_id` INT)");
+            Assert.assertFalse(this.migrator.scopeColumnsExist(conn,
+                                                               "scope_guard"));
+            statement.execute("ALTER TABLE `scope_guard` ADD COLUMN " +
+                              "`graphspace` VARCHAR(48)");
+            statement.execute("ALTER TABLE `scope_guard` ADD COLUMN " +
+                              "`graph` VARCHAR(48)");
+            Assert.assertTrue(this.migrator.scopeColumnsExist(conn,
+                                                              "scope_guard"));
+            statement.execute("DROP TABLE `scope_guard`");
+        }
+    }
+
+    @Test
+    public void testMysqlUsesDropIndexForLegacyUniqueKeys() {
+        Assert.assertEquals("ALTER TABLE `gremlin_collection` DROP INDEX " +
+                            "`legacy_name_key`",
+                            DatabaseSchemaMigrator.dropUniqueKeySql(
+                                    "MySQL", "legacy_name_key"));
+    }
+
+    private void assertScopedRows(Connection conn) throws Exception {
+        try (Statement statement = conn.createStatement();
+             ResultSet rows = statement.executeQuery(
+                     "SELECT `conn_id`, `graphspace`, `graph` FROM " +
+                     "`execute_history`")) {
+            Assert.assertTrue(rows.next());
+            Assert.assertNull(rows.getObject(1));
+            Assert.assertEquals("DEFAULT", rows.getString(2));
+            Assert.assertEquals("legacygraph", rows.getString(3));
+        }
+    }
+
+    private static void prepareLegacyDatabase() {
+        Path database = Paths.get("target/legacy-schema-startup.mv.db");
+        try {
+            Files.createDirectories(database.getParent());
+            Files.deleteIfExists(database);
+            try (Connection conn = DriverManager.getConnection(JDBC_URL,
+                                                               "sa", "");
+                 Statement statement = conn.createStatement()) {
+                ScriptUtils.executeSqlScript(conn, new FileSystemResource(
+                        Paths.get("src/test/resources/database/schema.sql")));
+                statement.execute("INSERT INTO `graph_connection` " +
+                                  "(`name`, `graph`, `host`, `port`, " +
+                                  "`create_time`) VALUES ('legacy', " +
+                                  "'legacygraph', 'localhost', 8080, " +
+                                  "CURRENT_TIMESTAMP)");
+                statement.execute("INSERT INTO `execute_history` " +
+                                  "(`execute_type`, `content`, " +
+                                  "`execute_status`, `duration`, " +
+                                  "`create_time`) VALUES " +
+                                  "(0, 'g.V()', 1, 10, CURRENT_TIMESTAMP)");
+                statement.execute("INSERT INTO `gremlin_collection` " +
+                                  "(`name`, `content`, `create_time`) VALUES " +
+                                  "('saved', 'g.E()', CURRENT_TIMESTAMP)");
+            }
+        } catch (Exception e) {
+            throw new ExceptionInInitializerError(e);
+        }
+    }
+
+    @SpringBootConfiguration
+    @EnableAutoConfiguration
+    @MapperScan("org.apache.hugegraph.mapper.query")
+    @Import({DatabaseSchemaMigrator.class, MybatisPlusConfig.class,
+             ExecuteHistoryService.class, GremlinCollectionService.class})
+    public static class TestConfiguration {
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/op/HStoreControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/op/HStoreControllerTest.java
new file mode 100644
index 000000000..92b1462be
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/op/HStoreControllerTest.java
@@ -0,0 +1,114 @@
+/*
+ *
+ * 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.hugegraph.controller.op;
+
+import java.util.Map;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import org.apache.hugegraph.driver.HStoreManager;
+import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.exception.ServerCapabilityUnavailableException;
+import org.apache.hugegraph.exception.ServerException;
+import org.apache.hugegraph.testutil.Assert;
+
+public class HStoreControllerTest {
+
+    @Test
+    public void testMissingApiStatusesMapToCapabilityUnavailable() {
+        for (int status : new int[]{404, 405, 501}) {
+            ServerException server = serverException(status);
+            RuntimeException mapped = HStoreController.mapHstoreFailure(
+                                      server, "node-2", "node-1");
+            Assert.assertTrue(mapped instanceof
+                              ServerCapabilityUnavailableException);
+            Assert.assertSame(server, mapped.getCause());
+        }
+    }
+
+    @Test
+    public void testOtherServerFailuresRemainUnchanged() {
+        for (int status : new int[]{400, 401, 403, 409, 500, 503}) {
+            ServerException server = serverException(status);
+            Assert.assertSame(server,
+                              HStoreController.mapHstoreFailure(server));
+        }
+    }
+
+    @Test
+    public void testStartupFailureReportsSuccessfulAndFailedNodes() {
+        this.assertPartialNodeFailure(true);
+    }
+
+    @Test
+    public void testShutdownFailureReportsSuccessfulAndFailedNodes() {
+        this.assertPartialNodeFailure(false);
+    }
+
+    private void assertPartialNodeFailure(boolean startup) {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        HStoreManager manager = Mockito.mock(HStoreManager.class);
+        Mockito.when(client.hStoreManager()).thenReturn(manager);
+        ServerException server = serverException(404);
+        if (startup) {
+            Mockito.doThrow(server).when(manager).nodeStartup("node-2");
+        } else {
+            Mockito.doThrow(server).when(manager).nodeShutdown("node-2");
+        }
+        HStoreController controller = new TestHStoreController(client);
+        Map<String, java.util.List<String>> request = ImmutableMap.of(
+                "nodes", ImmutableList.of("node-1", "node-2"));
+
+        try {
+            if (startup) {
+                controller.nodesStartup(request);
+            } else {
+                controller.nodesShutdown(request);
+            }
+            Assert.fail("Expected unavailable HStore capability");
+        } catch (ServerCapabilityUnavailableException e) {
+            Assert.assertSame(server, e.getCause());
+            Assert.assertEquals("node-2", e.args()[0]);
+            Assert.assertEquals(ImmutableList.of("node-1"), e.args()[1]);
+        }
+    }
+
+    private static ServerException serverException(int status) {
+        ServerException server = new ServerException("upstream-" + status);
+        server.status(status);
+        return server;
+    }
+
+    private static final class TestHStoreController extends HStoreController {
+
+        private final HugeClient client;
+
+        private TestHStoreController(HugeClient client) {
+            this.client = client;
+        }
+
+        @Override
+        protected HugeClient authClient(String graphSpace, String graph) {
+            return this.client;
+        }
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java
index 1a57d89a4..3d90b6f01 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java
@@ -20,6 +20,11 @@ package org.apache.hugegraph.service.load;
 
 import java.util.Collections;
 import java.util.Date;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import javax.sql.DataSource;
@@ -55,6 +60,7 @@ import org.apache.hugegraph.entity.load.LoadParameter;
 import org.apache.hugegraph.entity.load.LoadTask;
 import org.apache.hugegraph.handler.LoadTaskExecutor;
 import org.apache.hugegraph.loader.executor.LoadOptions;
+import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.service.schema.EdgeLabelService;
 import org.apache.hugegraph.service.schema.VertexLabelService;
 import org.apache.hugegraph.testutil.Assert;
@@ -79,14 +85,21 @@ import org.apache.hugegraph.testutil.Assert;
                 })
 public class IngestTransactionIntegrationTest {
 
+    private static final String UPLOAD_ROOT =
+            "/tmp/hubble-ingest-transaction-test";
+
     @Autowired
     private JobManagerService jobService;
     @Autowired
+    private FileMappingService mappingService;
+    @Autowired
     private TestLoadTaskService taskService;
     @Autowired
     private RecordingLoadTaskExecutor executor;
     @Autowired
     private DataSource dataSource;
+    @Autowired
+    private HugeConfig config;
     @MockBean
     private VertexLabelService vertexLabelService;
     @MockBean
@@ -100,6 +113,8 @@ public class IngestTransactionIntegrationTest {
         this.cleanup();
         this.executor.reset();
         this.taskService.failTaskInsert(false);
+        Mockito.when(this.config.get(HubbleOptions.UPLOAD_FILE_LOCATION))
+               .thenReturn(UPLOAD_ROOT);
     }
 
     @After
@@ -180,6 +195,99 @@ public class IngestTransactionIntegrationTest {
         Assert.assertEquals("canary-server-token", task.getOptions().token);
     }
 
+    @Test
+    public void testConcurrentQuotaChangesAreAtomic() throws Exception {
+        JobManager job = this.job();
+        job.setJobSize(0L);
+        this.jobService.save(job);
+        FileMapping first = this.persistedMapping(job.getId(), "first.csv");
+        FileMapping second = this.persistedMapping(job.getId(), "second.csv");
+
+        this.runConcurrently(
+                () -> this.jobService.completeUpload(first),
+                () -> this.jobService.completeUpload(second));
+
+        Assert.assertEquals(16L, this.jobService.get(job.getId()).getJobSize());
+
+        this.runConcurrently(
+                () -> this.jobService.deleteMappings(
+                        job.getId(), Collections.singletonList(first)),
+                () -> this.jobService.deleteMappings(
+                        job.getId(), Collections.singletonList(second)));
+
+        Assert.assertEquals(0L, this.jobService.get(job.getId()).getJobSize());
+        Assert.assertEquals(0, this.count("file_mapping"));
+    }
+
+    @Test
+    public void testQuotaFailureRollsBackMappingDeletion() {
+        FileMapping mapping = this.persistedMapping(999, "orphan.csv");
+
+        Assert.assertThrows(RuntimeException.class, () ->
+            this.jobService.deleteMappings(
+                    999, Collections.singletonList(mapping)));
+
+        Assert.assertEquals(1, this.count("file_mapping"));
+    }
+
+    @Test
+    public void testQuotaFailureRollsBackUploadCompletion() {
+        FileMapping mapping = this.mapping();
+        mapping.setJobId(999);
+        mapping.setFileStatus(FileMappingStatus.UPLOADING);
+        this.mappingService.save(mapping);
+
+        mapping.setFileStatus(FileMappingStatus.COMPLETED);
+        Assert.assertThrows(RuntimeException.class, () ->
+            this.jobService.completeUpload(mapping));
+
+        Integer status = this.jdbc.queryForObject(
+                         "SELECT file_status FROM file_mapping WHERE id = ?",
+                         Integer.class, mapping.getId());
+        Assert.assertEquals((int) FileMappingStatus.UPLOADING.getValue(),
+                            status.intValue());
+    }
+
+    @Test
+    public void testFailedMappingDeletionDoesNotReleaseCompletedQuota() {
+        JobManager job = this.job();
+        job.setJobSize(8L);
+        this.jobService.save(job);
+        this.persistedMapping(job.getId(), "completed.csv");
+        FileMapping failed = this.persistedMapping(job.getId(), "failed.csv");
+        failed.setFileStatus(FileMappingStatus.FAILURE);
+        this.mappingService.update(failed);
+
+        this.jobService.deleteMappings(
+                job.getId(), Collections.singletonList(failed));
+
+        Assert.assertEquals(8L, this.jobService.get(job.getId()).getJobSize());
+    }
+
+    @Test
+    public void testFailedDiskCleanupLeavesRetryableOrphanRecord() {
+        JobManager job = this.job();
+        job.setJobSize(8L);
+        this.jobService.save(job);
+        FileMapping mapping = this.persistedMapping(job.getId(), "orphan.csv");
+        mapping.setPath("/outside-upload-root/orphan.csv");
+        this.mappingService.update(mapping);
+
+        this.jobService.deleteMappings(
+                job.getId(), Collections.singletonList(mapping));
+
+        Assert.assertEquals(1, this.count("file_mapping"));
+        Assert.assertEquals(-mapping.getId(),
+                            this.jdbc.queryForObject(
+                            "SELECT job_id FROM file_mapping WHERE id = ?",
+                            Integer.class, mapping.getId()).intValue());
+
+        this.jdbc.update("UPDATE file_mapping SET path = ? WHERE id = ?",
+                         UPLOAD_ROOT + "/retry/orphan.csv", mapping.getId());
+        this.mappingService.deleteOrphanedJobFiles();
+        Assert.assertEquals(0, this.count("file_mapping"));
+    }
+
     @Test
     public void testRejectedDispatchCommitsFailedTaskAndJobWithoutThrowing() {
         this.executor.reject(true);
@@ -234,6 +342,48 @@ public class IngestTransactionIntegrationTest {
         return mapping;
     }
 
+    private FileMapping persistedMapping(int jobId, String name) {
+        FileMapping mapping = this.mapping();
+        mapping.setJobId(jobId);
+        mapping.setName(name);
+        mapping.setPath(UPLOAD_ROOT + "/" + name + "/" + name);
+        this.mappingService.save(mapping);
+        return mapping;
+    }
+
+    private void runConcurrently(Runnable first, Runnable second)
+            throws Exception {
+        ExecutorService pool = Executors.newFixedThreadPool(2);
+        CountDownLatch ready = new CountDownLatch(2);
+        CountDownLatch start = new CountDownLatch(1);
+        try {
+            Future<?> firstFuture = pool.submit(
+                    () -> runAfterSignal(first, ready, start));
+            Future<?> secondFuture = pool.submit(
+                    () -> runAfterSignal(second, ready, start));
+            Assert.assertTrue(ready.await(5L, TimeUnit.SECONDS));
+            start.countDown();
+            firstFuture.get(10L, TimeUnit.SECONDS);
+            secondFuture.get(10L, TimeUnit.SECONDS);
+        } finally {
+            pool.shutdownNow();
+        }
+    }
+
+    private static void runAfterSignal(Runnable action, CountDownLatch ready,
+                                       CountDownLatch start) {
+        ready.countDown();
+        try {
+            if (!start.await(5L, TimeUnit.SECONDS)) {
+                throw new IllegalStateException("Concurrent test start timed out");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Concurrent test interrupted", e);
+        }
+        action.run();
+    }
+
     private GraphConnection connection() {
         GraphConnection connection = new GraphConnection();
         connection.setGraphSpace("DEFAULT");
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/query/ExecuteHistoryRetentionTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/query/ExecuteHistoryRetentionTest.java
new file mode 100644
index 000000000..2cfb7bff2
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/query/ExecuteHistoryRetentionTest.java
@@ -0,0 +1,188 @@
+/*
+ *
+ * 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.hugegraph.service.query;
+
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import javax.sql.DataSource;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import org.apache.hugegraph.entity.query.ExecuteHistory;
+import org.apache.hugegraph.mapper.query.ExecuteHistoryMapper;
+import org.apache.hugegraph.testutil.Assert;
+import org.mockito.Mockito;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = ExecuteHistoryRetentionTest.TestConfiguration.class,
+                webEnvironment = SpringBootTest.WebEnvironment.NONE,
+                properties = {
+                    "spring.datasource.url=jdbc:h2:mem:execute-history-retention;" +
+                    "DB_CLOSE_DELAY=-1",
+                    "spring.datasource.driver-class-name=org.h2.Driver",
+                    "spring.datasource.username=sa",
+                    "spring.datasource.password=",
+                    "spring.datasource.initialization-mode=never",
+                    "spring.datasource.hikari.maximum-pool-size=2",
+                    "spring.autoconfigure.exclude=" +
+                    "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration",
+                    "mybatis.configuration.map-underscore-to-camel-case=true",
+                    "mybatis.configuration.use-generated-keys=true",
+                    "mybatis-plus.type-enums-package=org.apache.hugegraph.entity.enums"
+                })
+public class ExecuteHistoryRetentionTest {
+
+    private static final String SPACE = "DEFAULT";
+
+    @Autowired
+    private ExecuteHistoryMapper mapper;
+    @Autowired
+    private DataSource dataSource;
+
+    private JdbcTemplate jdbc;
+
+    @Before
+    public void setup() {
+        this.jdbc = new JdbcTemplate(this.dataSource);
+        this.jdbc.execute("CREATE TABLE IF NOT EXISTS `execute_history` (" +
+                          "`id` INT NOT NULL AUTO_INCREMENT, " +
+                          "`conn_id` INT, " +
+                          "`graphspace` VARCHAR(48) NOT NULL, " +
+                          "`graph` VARCHAR(48) NOT NULL, " +
+                          "`async_id` BIGINT NOT NULL, " +
+                          "`execute_type` TINYINT NOT NULL, " +
+                          "`content` VARCHAR(65535) NOT NULL, " +
+                          "`text` VARCHAR(65535) NOT NULL, " +
+                          "`execute_status` TINYINT NOT NULL, " +
+                          "`failure_reason` VARCHAR(64) DEFAULT NULL, " +
+                          "`async_status` TINYINT NOT NULL DEFAULT 0, " +
+                          "`duration` BIGINT NOT NULL, " +
+                          "`create_time` DATETIME(6) NOT NULL, " +
+                          "PRIMARY KEY (`id`))");
+        this.jdbc.update("DELETE FROM `execute_history`");
+    }
+
+    @Test
+    public void testKeepsNewestRowsPerGraphAndDeletesTheRest() {
+        // A busy graph and a quiet one, interleaved in time
+        for (int i = 0; i < 6; i++) {
+            this.insert("busy", "busy-" + i, 1000L + (i * 1000L));
+        }
+        this.insert("quiet", "quiet-0", 1000L);
+        this.insert("quiet", "quiet-1", 2000L);
+
+        this.mapper.deleteExceedLimit(2);
+
+        // The quiet graph is untouched, the busy one keeps only the newest 2
+        Assert.assertEquals(2, this.count("quiet"));
+        Assert.assertEquals(2, this.count("busy"));
+        List<String> kept = this.jdbc.queryForList(
+                "SELECT `content` FROM `execute_history` " +
+                "WHERE `graph` = 'busy' ORDER BY `create_time` DESC",
+                String.class);
+        Assert.assertEquals(2, kept.size());
+        Assert.assertEquals("busy-5", kept.get(0));
+        Assert.assertEquals("busy-4", kept.get(1));
+    }
+
+    @Test
+    public void testKeepsEverythingWhenUnderTheLimit() {
+        this.insert("small", "only-0", 1000L);
+        this.insert("small", "only-1", 2000L);
+
+        this.mapper.deleteExceedLimit(500);
+
+        Assert.assertEquals(2, this.count("small"));
+    }
+
+    @Test
+    public void testDeletesExcessRowsInBoundedBatches() {
+        for (int i = 0; i < 505; i++) {
+            this.insert("busy", "busy-" + i, 1000L + i);
+        }
+
+        this.mapper.deleteExceedLimit(2);
+
+        Assert.assertEquals(2, this.count("busy"));
+        List<String> kept = this.jdbc.queryForList(
+                "SELECT `content` FROM `execute_history` " +
+                "WHERE `graph` = 'busy' ORDER BY `create_time` DESC",
+                String.class);
+        Assert.assertEquals("busy-504", kept.get(0));
+        Assert.assertEquals("busy-503", kept.get(1));
+    }
+
+    @Test
+    public void testRechecksExcessAfterSelectingCandidates() {
+        ExecuteHistoryMapper concurrent = Mockito.mock(
+                ExecuteHistoryMapper.class, Mockito.CALLS_REAL_METHODS);
+        ExecuteHistory scope = new ExecuteHistory();
+        scope.setGraphspace(SPACE);
+        scope.setGraph("busy");
+        Mockito.when(concurrent.findScopes()).thenReturn(
+                Collections.singletonList(scope));
+        Mockito.when(concurrent.countScope(SPACE, "busy")).thenReturn(4, 2);
+        Mockito.when(concurrent.findIdsOldestFirst(SPACE, "busy", 2))
+               .thenReturn(Arrays.asList(1L, 2L));
+
+        concurrent.deleteExceedLimit(2);
+
+        Mockito.verify(concurrent, Mockito.times(2))
+               .countScope(SPACE, "busy");
+        Mockito.verify(concurrent, Mockito.never())
+               .deleteBatchIds(Mockito.anyList());
+    }
+
+    private void insert(String graph, String content, long millis) {
+        this.jdbc.update("INSERT INTO `execute_history` " +
+                         "(`graphspace`, `graph`, `async_id`, " +
+                         "`execute_type`, `content`, `text`, " +
+                         "`execute_status`, `async_status`, `duration`, " +
+                         "`create_time`) VALUES (?, ?, 0, 1, ?, '', 1, 0, " +
+                         "0, ?)",
+                         SPACE, graph, content, new Timestamp(millis));
+    }
+
+    private int count(String graph) {
+        return this.jdbc.queryForObject(
+                "SELECT COUNT(*) FROM `execute_history` " +
+                "WHERE `graphspace` = ? AND `graph` = ?",
+                Integer.class, SPACE, graph);
+    }
+
+    @SpringBootConfiguration
+    @EnableAutoConfiguration
+    @EnableTransactionManagement
+    @MapperScan("org.apache.hugegraph.mapper.query")
+    public static class TestConfiguration {
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java
new file mode 100644
index 000000000..1656cf852
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java
@@ -0,0 +1,110 @@
+/*
+ *
+ * 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.hugegraph.unit;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import org.apache.hugegraph.controller.load.FileMappingController;
+import org.apache.hugegraph.entity.enums.LoadStatus;
+import org.apache.hugegraph.entity.load.FileMapping;
+import org.apache.hugegraph.entity.load.LoadTask;
+import org.apache.hugegraph.exception.ExternalException;
+import org.apache.hugegraph.service.load.FileMappingService;
+import org.apache.hugegraph.service.load.JobManagerService;
+import org.apache.hugegraph.service.load.LoadTaskService;
+import org.apache.hugegraph.testutil.Assert;
+
+public class FileMappingDeletionTest {
+
+    @Test
+    public void testDeleteProtectsEveryRecoverableTaskStatus()
+            throws Exception {
+        for (LoadStatus status : new LoadStatus[]{LoadStatus.RUNNING,
+                                                  LoadStatus.PAUSED,
+                                                  LoadStatus.FAILED,
+                                                  LoadStatus.STOPPED}) {
+            Fixture fixture = this.fixture(status);
+
+            Assert.assertThrows(ExternalException.class, () ->
+                fixture.controller.delete("DEFAULT", "hugegraph", 1, 7));
+
+            Mockito.verify(fixture.jobService, Mockito.never())
+                   .deleteMappings(Mockito.anyInt(), Mockito.anyList());
+        }
+    }
+
+    @Test
+    public void testDeleteAllowsTerminalTaskAndUsesTransactionalService()
+            throws Exception {
+        Fixture fixture = this.fixture(LoadStatus.SUCCEED);
+
+        fixture.controller.delete("DEFAULT", "hugegraph", 1, 7);
+
+        Mockito.verify(fixture.jobService).deleteMappings(
+                1, Collections.singletonList(fixture.mapping));
+        Mockito.verify(fixture.mappingService, Mockito.never())
+               .deleteDiskFile(Mockito.any());
+        Mockito.verify(fixture.mappingService, Mockito.never())
+               .remove(Mockito.anyInt());
+    }
+
+    private Fixture fixture(LoadStatus status) throws Exception {
+        Fixture fixture = new Fixture();
+        fixture.controller = new FileMappingController();
+        fixture.mappingService = Mockito.mock(FileMappingService.class);
+        fixture.jobService = Mockito.mock(JobManagerService.class);
+        LoadTaskService taskService = Mockito.mock(LoadTaskService.class);
+        fixture.mapping = new FileMapping();
+        fixture.mapping.setId(7);
+        fixture.mapping.setJobId(1);
+        fixture.mapping.setTotalSize(8L);
+        LoadTask task = LoadTask.builder()
+                                .jobId(1)
+                                .fileId(7)
+                                .status(status)
+                                .build();
+        Mockito.when(fixture.mappingService.get("DEFAULT", "hugegraph", 1, 7))
+               .thenReturn(fixture.mapping);
+        Mockito.when(taskService.taskListByJob(1))
+               .thenReturn(Collections.singletonList(task));
+        setField(fixture.controller, "service", fixture.mappingService);
+        setField(fixture.controller, "jobService", fixture.jobService);
+        setField(fixture.controller, "taskService", taskService);
+        return fixture;
+    }
+
+    private static void setField(Object target, String name, Object value)
+            throws Exception {
+        Field field = target.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static final class Fixture {
+
+        private FileMappingController controller;
+        private FileMappingService mappingService;
+        private JobManagerService jobService;
+        private FileMapping mapping;
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java
index 0327cae3c..40a922d6b 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java
@@ -25,7 +25,10 @@ import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.SQLException;
 import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Set;
 
 import org.junit.Test;
 import org.springframework.core.io.FileSystemResource;
@@ -126,6 +129,279 @@ public class FileMappingSchemaTest {
         }
     }
 
+    @Test
+    public void testSchemaMigratorAddsMissingLegacyColumns() throws Exception {
+        String url = "jdbc:h2:mem:legacy_schema_columns;DB_CLOSE_DELAY=-1";
+        try (Connection conn = DriverManager.getConnection(url)) {
+            ScriptUtils.executeSqlScript(conn, new FileSystemResource(
+                                         this.legacySchemaPath()));
+            try (Statement statement = conn.createStatement()) {
+                statement.execute("INSERT INTO `graph_connection` " +
+                                  "(`name`, `graph`, `host`, `port`, " +
+                                  "`create_time`) VALUES " +
+                                  "('legacy', 'legacygraph', 'localhost', " +
+                                  "8080, CURRENT_TIMESTAMP)");
+                statement.execute("INSERT INTO `execute_history` " +
+                                  "(`execute_type`, `content`, " +
+                                  "`execute_status`, `duration`, " +
+                                  "`create_time`) VALUES " +
+                                  "(1, 'g.V()', 1, 10, CURRENT_TIMESTAMP)");
+                statement.execute("INSERT INTO `gremlin_collection` " +
+                                  "(`name`, `content`, `create_time`) " +
+                                  "VALUES ('saved', 'g.E()', " +
+                                  "CURRENT_TIMESTAMP)");
+            }
+
+            DatabaseSchemaMigrator migrator = new DatabaseSchemaMigrator();
+            // Running twice must be a no-op the second time
+            migrator.migrate(conn);
+            migrator.migrate(conn);
+
+            for (String column : new String[]{"CONN_ID", "GRAPHSPACE",
+                                              "GRAPH", "ASYNC_ID", "TEXT",
+                                              "ASYNC_STATUS",
+                                              "FAILURE_REASON"}) {
+                this.assertColumnExists(conn, "EXECUTE_HISTORY", column);
+            }
+            for (String column : new String[]{"CONN_ID", "GRAPHSPACE",
+                                              "GRAPH", "TYPE"}) {
+                this.assertColumnExists(conn, "GREMLIN_COLLECTION", column);
+            }
+
+            // The legacy rows survive and the new columns are backfilled
+            try (Statement statement = conn.createStatement();
+                 ResultSet rs = statement.executeQuery(
+                         "SELECT `content`, `conn_id`, `graphspace`, `graph`, " +
+                         "`async_id`, `text`, `async_status` " +
+                         "FROM `execute_history`")) {
+                Assert.assertTrue(rs.next());
+                Assert.assertEquals("g.V()", rs.getString(1));
+                Assert.assertNull(rs.getObject(2));
+                Assert.assertEquals("DEFAULT", rs.getString(3));
+                Assert.assertEquals("legacygraph", rs.getString(4));
+                Assert.assertEquals(0L, rs.getLong(5));
+                Assert.assertEquals("", rs.getString(6));
+                Assert.assertEquals(0, rs.getInt(7));
+            }
+            try (Statement statement = conn.createStatement();
+                 ResultSet rs = statement.executeQuery(
+                         "SELECT `content`, `conn_id`, `graphspace`, `graph`, " +
+                         "`type` " +
+                         "FROM `gremlin_collection`")) {
+                Assert.assertTrue(rs.next());
+                Assert.assertEquals("g.E()", rs.getString(1));
+                Assert.assertNull(rs.getObject(2));
+                Assert.assertEquals("DEFAULT", rs.getString(3));
+                Assert.assertEquals("legacygraph", rs.getString(4));
+                Assert.assertEquals("GREMLIN", rs.getString(5));
+            }
+
+            // Names are unique only inside the application-visible scope.
+            try (Statement statement = conn.createStatement()) {
+                statement.execute("INSERT INTO `gremlin_collection` " +
+                                  "(`conn_id`, `graphspace`, `graph`, `name`, " +
+                                  "`type`, `content`, `create_time`) VALUES " +
+                                  "(1, 'DEFAULT', 'othergraph', 'saved', " +
+                                  "'GREMLIN', 'g.V()', CURRENT_TIMESTAMP)");
+                Assert.assertThrows(SQLException.class, () ->
+                    statement.execute("INSERT INTO `gremlin_collection` " +
+                                      "(`conn_id`, `graphspace`, `graph`, " +
+                                      "`name`, `type`, `content`, " +
+                                      "`create_time`) VALUES (1, 'DEFAULT', " +
+                                      "'legacygraph', 'saved', 'GREMLIN', " +
+                                      "'g.V()', CURRENT_TIMESTAMP)"));
+            }
+        }
+    }
+
+    @Test
+    public void testSchemaMigratorIsNoopOnFreshSchema() throws Exception {
+        String url = "jdbc:h2:mem:fresh_schema_columns;DB_CLOSE_DELAY=-1";
+        try (Connection conn = DriverManager.getConnection(url)) {
+            ScriptUtils.executeSqlScript(conn, new FileSystemResource(
+                                         this.mainSchemaPath()));
+
+            DatabaseSchemaMigrator migrator = new DatabaseSchemaMigrator();
+            migrator.migrate(conn);
+            migrator.migrate(conn);
+
+            this.assertColumnExists(conn, "EXECUTE_HISTORY", "GRAPHSPACE");
+            this.assertColumnExists(conn, "GREMLIN_COLLECTION", "TYPE");
+            this.assertIndexExists(conn, "EXECUTE_HISTORY",
+                                   "EXECUTE_HISTORY_GRAPH_CREATE_TIME");
+            this.assertIndexExists(conn, "FILE_MAPPING",
+                                   "FILE_MAPPING_JOB_ID");
+            this.assertIndexExists(conn, "LOAD_TASK", "LOAD_TASK_JOB_ID");
+            this.assertIndexExists(conn, "JOB_MANAGER",
+                                   "JOB_MANAGER_GRAPH_CREATE_TIME");
+            this.assertIndexExists(conn, "ASYNC_TASK", "ASYNC_TASK_GRAPH");
+            // The fresh schema keeps its own column types untouched
+            try (ResultSet columns = conn.getMetaData().getColumns(
+                    null, null, "EXECUTE_HISTORY", "GRAPHSPACE")) {
+                Assert.assertTrue(columns.next());
+                Assert.assertEquals(48, columns.getInt("COLUMN_SIZE"));
+            }
+        }
+    }
+
+    @Test
+    public void testSchemaMigratorRecoversScopeFromExistingConnectionId()
+           throws Exception {
+        String url = "jdbc:h2:mem:legacy_schema_conn_id;DB_CLOSE_DELAY=-1";
+        try (Connection conn = DriverManager.getConnection(url);
+             Statement statement = conn.createStatement()) {
+            ScriptUtils.executeSqlScript(conn, new FileSystemResource(
+                                         this.legacySchemaPath()));
+            statement.execute("INSERT INTO `graph_connection` " +
+                              "(`name`, `graph`, `host`, `port`, " +
+                              "`create_time`) VALUES " +
+                              "('first', 'graph_a', 'host_a', 8080, " +
+                              "CURRENT_TIMESTAMP), " +
+                              "('second', 'graph_b', 'host_b', 8080, " +
+                              "CURRENT_TIMESTAMP)");
+            statement.execute("ALTER TABLE `execute_history` ADD COLUMN " +
+                              "`conn_id` INT");
+            statement.execute("ALTER TABLE `execute_history` ADD COLUMN " +
+                              "`graphspace` VARCHAR(48)");
+            statement.execute("ALTER TABLE `execute_history` ADD COLUMN " +
+                              "`graph` VARCHAR(48)");
+            statement.execute("INSERT INTO `execute_history` " +
+                              "(`conn_id`, `graphspace`, `graph`, " +
+                              "`execute_type`, `content`, `execute_status`, " +
+                              "`duration`, `create_time`) VALUES " +
+                              "(2, '', '', 0, 'g.V()', 1, 1, " +
+                              "CURRENT_TIMESTAMP)");
+
+            new DatabaseSchemaMigrator().migrate(conn);
+
+            try (ResultSet rows = statement.executeQuery(
+                    "SELECT `graphspace`, `graph` FROM `execute_history`")) {
+                Assert.assertTrue(rows.next());
+                Assert.assertEquals("DEFAULT", rows.getString(1));
+                Assert.assertEquals("graph_b", rows.getString(2));
+            }
+        }
+    }
+
+    @Test
+    public void testSchemaMigratorPreservesCollidingLegacyCollections()
+           throws Exception {
+        String url = "jdbc:h2:mem:legacy_collection_collision;DB_CLOSE_DELAY=-1";
+        try (Connection conn = DriverManager.getConnection(url);
+             Statement statement = conn.createStatement()) {
+            statement.execute("CREATE TABLE `graph_connection` (" +
+                              "`id` INT NOT NULL AUTO_INCREMENT, " +
+                              "`graphspace` VARCHAR(48), " +
+                              "`graph` VARCHAR(48), PRIMARY KEY (`id`))");
+            statement.execute("CREATE TABLE `gremlin_collection` (" +
+                              "`id` INT NOT NULL AUTO_INCREMENT, " +
+                              "`conn_id` INT, `graphspace` VARCHAR(48), " +
+                              "`graph` VARCHAR(48), `name` VARCHAR(48), " +
+                              "`type` VARCHAR(48), `content` TEXT, " +
+                              "PRIMARY KEY (`id`), UNIQUE (`conn_id`, `name`))");
+            statement.execute("INSERT INTO `graph_connection` " +
+                              "(`graphspace`, `graph`) VALUES " +
+                              "('DEFAULT', 'shared'), ('DEFAULT', 'shared')");
+            statement.execute("INSERT INTO `gremlin_collection` " +
+                              "(`conn_id`, `name`, `content`) VALUES " +
+                              "(NULL, 'saved', 'first'), " +
+                              "(NULL, 'saved', 'second'), " +
+                              "(NULL, 'saved_2', 'reserved')");
+
+            DatabaseSchemaMigrator migrator = new DatabaseSchemaMigrator();
+            migrator.migrate(conn);
+            migrator.migrate(conn);
+
+            Set<String> names = new HashSet<>();
+            Set<String> contents = new HashSet<>();
+            try (ResultSet rows = statement.executeQuery(
+                    "SELECT `conn_id`, `name`, `content` " +
+                    "FROM `gremlin_collection`")) {
+                while (rows.next()) {
+                    Assert.assertNull(rows.getObject(1));
+                    names.add(rows.getString(2));
+                    contents.add(rows.getString(3));
+                }
+            }
+            Assert.assertEquals(3, names.size());
+            Assert.assertTrue(names.contains("saved"));
+            Assert.assertTrue(names.contains("saved_2"));
+            Assert.assertTrue(names.stream().anyMatch(
+                    name -> name.startsWith("saved_2_") &&
+                            !name.equals("saved_2")));
+            Assert.assertEquals(3, contents.size());
+            Assert.assertTrue(contents.contains("first"));
+            Assert.assertTrue(contents.contains("second"));
+            Assert.assertTrue(contents.contains("reserved"));
+        }
+    }
+
+    @Test
+    public void testSchemaMigratorAddsMissingIndexesIdempotently()
+           throws Exception {
+        String url = "jdbc:h2:mem:legacy_schema_indexes;DB_CLOSE_DELAY=-1";
+        try (Connection conn = DriverManager.getConnection(url);
+             Statement statement = conn.createStatement()) {
+            statement.execute("CREATE TABLE `execute_history` (" +
+                              "`id` INT, `graphspace` VARCHAR(48), " +
+                              "`graph` VARCHAR(48), `create_time` DATETIME)");
+            statement.execute("CREATE TABLE `file_mapping` (" +
+                              "`id` INT, `job_id` INT)");
+            statement.execute("CREATE TABLE `load_task` (" +
+                              "`id` INT, `job_id` INT, `options` TEXT)");
+            statement.execute("CREATE TABLE `job_manager` (" +
+                              "`id` INT, `graphspace` VARCHAR(48), " +
+                              "`graph` VARCHAR(48), `create_time` DATETIME)");
+            statement.execute("CREATE TABLE `async_task` (" +
+                              "`id` INT, `graphspace` VARCHAR(48), " +
+                              "`graph` VARCHAR(48))");
+
+            DatabaseSchemaMigrator migrator = new DatabaseSchemaMigrator();
+            migrator.migrate(conn);
+            migrator.migrate(conn);
+
+            this.assertIndexExists(conn, "EXECUTE_HISTORY",
+                                   "EXECUTE_HISTORY_GRAPH_CREATE_TIME");
+            this.assertIndexExists(conn, "FILE_MAPPING",
+                                   "FILE_MAPPING_JOB_ID");
+            this.assertIndexExists(conn, "LOAD_TASK", "LOAD_TASK_JOB_ID");
+            this.assertIndexExists(conn, "JOB_MANAGER",
+                                   "JOB_MANAGER_GRAPH_CREATE_TIME");
+            this.assertIndexExists(conn, "ASYNC_TASK", "ASYNC_TASK_GRAPH");
+        }
+    }
+
+    private void assertColumnExists(Connection conn, String table,
+                                    String column) throws Exception {
+        try (ResultSet columns = conn.getMetaData().getColumns(
+                null, null, table, column)) {
+            Assert.assertTrue(table + "." + column + " should exist",
+                              columns.next());
+        }
+    }
+
+    private void assertIndexExists(Connection conn, String table,
+                                   String index) throws Exception {
+        try (ResultSet indexes = conn.getMetaData().getIndexInfo(
+                conn.getCatalog(), null, table, false, false)) {
+            while (indexes.next()) {
+                if (index.equalsIgnoreCase(indexes.getString("INDEX_NAME"))) {
+                    return;
+                }
+            }
+        }
+        Assert.fail(table + "." + index + " should exist");
+    }
+
+    private Path legacySchemaPath() {
+        Path modulePath = Paths.get("src/test/resources/database/schema.sql");
+        if (Files.exists(modulePath)) {
+            return modulePath;
+        }
+        return Paths.get("hugegraph-hubble/hubble-be/src/test/resources/" +
+                         "database/schema.sql");
+    }
+
     private void insertDeepPath(Connection conn, String deepPath)
             throws Exception {
         try (PreparedStatement insert = conn.prepareStatement(
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileUploadControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileUploadControllerTest.java
index d6b251789..269d71d07 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileUploadControllerTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileUploadControllerTest.java
@@ -240,6 +240,55 @@ public class FileUploadControllerTest {
         }
     }
 
+    @Test
+    public void testRestoreMovedUploadRestoresFileAndRemovesDirectory()
+           throws Exception {
+        Path uploadRoot = Files.createTempDirectory("hubble-upload-restore");
+        try {
+            Path movedDir = Files.createDirectories(
+                            uploadRoot.resolve("file-mapping-2"));
+            Path movedFile = Files.write(movedDir.resolve("data.csv"),
+                                         "id,name".getBytes());
+            Path originalFile = uploadRoot.resolve("data.csv");
+            FileMappingService service = this.fileMappingService(uploadRoot);
+
+            service.restoreMovedUpload(movedFile.toString(),
+                                       originalFile.toString());
+
+            Assert.assertTrue(Files.exists(originalFile));
+            Assert.assertFalse(Files.exists(movedFile));
+            Assert.assertFalse(Files.exists(movedDir));
+        } finally {
+            FileUtils.deleteDirectory(uploadRoot.toFile());
+        }
+    }
+
+    @Test
+    public void testRestoreMovedUploadRejectsSymlinkedDestinationParent()
+           throws Exception {
+        Path uploadRoot = Files.createTempDirectory("hubble-upload-root");
+        Path outsideRoot = Files.createTempDirectory("hubble-upload-outside");
+        try {
+            Path movedDir = Files.createDirectories(
+                            uploadRoot.resolve("file-mapping-2"));
+            Path movedFile = Files.write(movedDir.resolve("data.csv"),
+                                         "id,name".getBytes());
+            Path linkedParent = uploadRoot.resolve("linked");
+            Files.createSymbolicLink(linkedParent, outsideRoot);
+            Path originalFile = linkedParent.resolve("data.csv");
+            FileMappingService service = this.fileMappingService(uploadRoot);
+
+            Assert.assertThrows(InternalException.class, () ->
+                    service.restoreMovedUpload(movedFile.toString(),
+                                               originalFile.toString()));
+            Assert.assertTrue(Files.exists(movedFile));
+            Assert.assertFalse(Files.exists(outsideRoot.resolve("data.csv")));
+        } finally {
+            FileUtils.deleteDirectory(uploadRoot.toFile());
+            FileUtils.deleteDirectory(outsideRoot.toFile());
+        }
+    }
+
     @Test
     public void testTryMergePartFilesReplacesStaleAllFile() throws Exception {
         Path parent = Files.createTempDirectory("hubble-merge");
@@ -262,6 +311,78 @@ public class FileUploadControllerTest {
         }
     }
 
+    @Test
+    public void testFinalizeUploadRestoresMovedFileAfterDatabaseFailure()
+           throws Exception {
+        FileUploadController controller = this.controller("csv");
+        FileMappingService service = Mockito.mock(FileMappingService.class);
+        JobManagerService jobService = Mockito.mock(JobManagerService.class);
+        FileMapping mapping = this.completedMapping("upload/data.csv");
+        Mockito.when(service.moveToNextLevelDir(mapping))
+               .thenReturn("upload/file-mapping-2/data.csv");
+        Mockito.doThrow(new InternalException("database failure"))
+               .when(jobService).completeUpload(mapping);
+        this.setField(controller, "service", service);
+        this.setField(controller, "jobService", jobService);
+
+        Assert.assertThrows(InternalException.class,
+                            () -> this.finalizeUpload(controller, mapping));
+
+        Mockito.verify(service).restoreMovedUpload(
+                "upload/file-mapping-2/data.csv", "upload/data.csv");
+        Mockito.verify(service, Mockito.never()).update(mapping);
+        Assert.assertEquals("upload/data.csv", mapping.getPath());
+        Assert.assertEquals(FileMappingStatus.UPLOADING,
+                            mapping.getFileStatus());
+    }
+
+    @Test
+    public void testFinalizeUploadRetainsCleanupPathWhenRestoreFails()
+           throws Exception {
+        FileUploadController controller = this.controller("csv");
+        FileMappingService service = Mockito.mock(FileMappingService.class);
+        JobManagerService jobService = Mockito.mock(JobManagerService.class);
+        FileMapping mapping = this.completedMapping("upload/data.csv");
+        String movedPath = "upload/file-mapping-2/data.csv";
+        Mockito.when(service.moveToNextLevelDir(mapping)).thenReturn(movedPath);
+        Mockito.doThrow(new InternalException("database failure"))
+               .when(jobService).completeUpload(mapping);
+        Mockito.doThrow(new InternalException("restore failure"))
+               .when(service).restoreMovedUpload(movedPath,
+                                                 "upload/data.csv");
+        this.setField(controller, "service", service);
+        this.setField(controller, "jobService", jobService);
+
+        Assert.assertThrows(InternalException.class,
+                            () -> this.finalizeUpload(controller, mapping));
+
+        Mockito.verify(service).update(mapping);
+        Assert.assertEquals(movedPath, mapping.getPath());
+        Assert.assertEquals(FileMappingStatus.FAILURE,
+                            mapping.getFileStatus());
+
+        JobManager job = JobManager.builder().id(1).jobSize(0L).build();
+        Mockito.when(jobService.get("DEFAULT", "hugegraph", 1))
+               .thenReturn(job);
+        Mockito.when(service.get("DEFAULT", "hugegraph", 1, "data.csv"))
+               .thenReturn(mapping);
+        Assert.assertThrows(ExternalException.class, () ->
+                this.reserveUploadQuota(controller, "DEFAULT", "hugegraph",
+                                        1, "data.csv", "upload/data.csv",
+                                        10L));
+        Mockito.verify(service).update(mapping);
+    }
+
+    private FileMapping completedMapping(String path) {
+        FileMapping mapping = new FileMapping("DEFAULT", "hugegraph",
+                                              "data.csv", path);
+        mapping.setId(2);
+        mapping.setJobId(1);
+        mapping.setFileStatus(FileMappingStatus.COMPLETED);
+        mapping.setTotalSize(10L);
+        return mapping;
+    }
+
     private FileUploadController controller(String... formats) throws Exception {
         FileUploadController controller = new FileUploadController();
         HugeConfig config = Mockito.mock(HugeConfig.class);
@@ -275,6 +396,16 @@ public class FileUploadControllerTest {
         return controller;
     }
 
+    private FileMappingService fileMappingService(Path uploadRoot)
+            throws Exception {
+        FileMappingService service = new FileMappingService();
+        HugeConfig config = Mockito.mock(HugeConfig.class);
+        Mockito.when(config.get(HubbleOptions.UPLOAD_FILE_LOCATION))
+               .thenReturn(uploadRoot.toString());
+        this.setField(service, "config", config);
+        return service;
+    }
+
     private void checkFileValid(FileUploadController controller, JobManager job,
                                 MockMultipartFile file, String fileName)
                                 throws Exception {
@@ -321,6 +452,22 @@ public class FileUploadControllerTest {
         }
     }
 
+    private void finalizeUpload(FileUploadController controller,
+                                FileMapping mapping) throws Exception {
+        Method method = FileUploadController.class.getDeclaredMethod(
+                        "finalizeUpload", FileMapping.class);
+        method.setAccessible(true);
+        try {
+            method.invoke(controller, mapping);
+        } catch (InvocationTargetException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof Exception) {
+                throw (Exception) cause;
+            }
+            throw e;
+        }
+    }
+
     private void checkTotalAndIndexValid(FileUploadController controller,
                                          int total, int index)
                                          throws Exception {
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinUtilTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinUtilTest.java
new file mode 100644
index 000000000..d4cdfc60c
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinUtilTest.java
@@ -0,0 +1,190 @@
+/*
+ *
+ * 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.hugegraph.unit;
+
+import org.junit.Test;
+
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.util.GremlinUtil;
+
+public class GremlinUtilTest {
+
+    private static final int LIMIT = 250;
+
+    @Test
+    public void testSuffixAppend() {
+        // Matched suffixes get '.limit(N)' appended
+        Assert.assertEquals("g.V().limit(250)",
+                            GremlinUtil.optimizeLimit("g.V()", LIMIT));
+        Assert.assertEquals("g.V().out().limit(250)",
+                            GremlinUtil.optimizeLimit("g.V().out()", LIMIT));
+        Assert.assertEquals("g.E().limit(250)",
+                            GremlinUtil.optimizeLimit("g.E()", LIMIT));
+        Assert.assertEquals("g.V().hasLabel('person').limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel('person')", LIMIT));
+        // Unmatched suffixes are left untouched
+        Assert.assertEquals("g.V().count()",
+                            GremlinUtil.optimizeLimit("g.V().count()", LIMIT));
+        // Multi-line: each line is optimized independently
+        Assert.assertEquals("g.V().limit(250)\ng.V().count()",
+                            GremlinUtil.optimizeLimit(
+                            "g.V()\ng.V().count()", LIMIT));
+    }
+
+    @Test
+    public void testMultilineChainReceivesOnlyOneTailLimit() {
+        Assert.assertEquals("g.V()\n  .hasLabel(\"person\")\n  " +
+                            ".out().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V()\n  .hasLabel(\"person\")\n  .out()",
+                            LIMIT));
+        Assert.assertEquals("g.V() // continue\n  .out().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V() // continue\n  .out()", LIMIT));
+        Assert.assertEquals("g.V()\n/* keep traversing */\n" +
+                            ".out().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V()\n/* keep traversing */\n.out()",
+                            LIMIT));
+    }
+
+    @Test
+    public void testApostropheInCommentDoesNotDisableLimit() {
+        // An apostrophe inside a comment must not be read as a string
+        // delimiter, otherwise the rest of the script is treated as one
+        // unterminated string and never receives its limit.
+        String optimized = GremlinUtil.optimizeLimit(
+                "// don't limit this\ng.V()", 250);
+        Assert.assertTrue(optimized, optimized.endsWith(".limit(250)"));
+        Assert.assertTrue(optimized, optimized.startsWith("// don't limit"));
+    }
+
+    @Test
+    public void testQuoteInCommentDoesNotLeakIntoNextStatement() {
+        String optimized = GremlinUtil.optimizeLimit(
+                "// it's here\ng.V();g.E()", 10);
+        Assert.assertEquals("// it's here\ng.V().limit(10);g.E().limit(10)",
+                            optimized);
+    }
+
+    @Test
+    public void testIgnoredCommentLine() {
+        Assert.assertEquals("// g.V()\ng.E().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "// g.V()\ng.E()", LIMIT));
+    }
+
+    @Test
+    public void testTrailingLineCommentKeepsLimitBeforeComment() {
+        Assert.assertEquals("g.V().limit(250) // trailing",
+                            GremlinUtil.optimizeLimit(
+                            "g.V() // trailing", LIMIT));
+    }
+
+    @Test
+    public void testBlockCommentDoesNotAffectStatementParsing() {
+        Assert.assertEquals("g.V().limit(250) /* '; \\\" */;" +
+                            "g.E().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V() /* '; \\\" */;g.E()", LIMIT));
+        Assert.assertEquals("g.V().limit(250) /* line one\n" +
+                            "line two */\ng.E().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V() /* line one\nline two */\ng.E()", LIMIT));
+    }
+
+    @Test
+    public void testPlainMultiStatement() {
+        Assert.assertEquals("g.V().limit(250);g.E().limit(250)",
+                            GremlinUtil.optimizeLimit("g.V();g.E()", LIMIT));
+        Assert.assertEquals("g.V().count();g.E().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().count();g.E()", LIMIT));
+        // Trailing separator kept, no limit appended to the empty tail
+        Assert.assertEquals("g.V().limit(250);",
+                            GremlinUtil.optimizeLimit("g.V();", LIMIT));
+    }
+
+    @Test
+    public void testSemicolonInsideSingleQuotedString() {
+        // The ';' inside the quoted string is not a statement separator
+        Assert.assertEquals("g.V().hasLabel('a;b').limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel('a;b')", LIMIT));
+        Assert.assertEquals("g.V().hasLabel('a;b').limit(250);" +
+                            "g.E().limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel('a;b');g.E()", LIMIT));
+    }
+
+    @Test
+    public void testSemicolonInsideDoubleQuotedString() {
+        Assert.assertEquals("g.V().hasLabel(\"a;b\").limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel(\"a;b\")", LIMIT));
+    }
+
+    @Test
+    public void testEscapedQuoteInsideString() {
+        // The escaped quote does not close the string, so the ';' and the
+        // fake suffix inside it are ignored
+        Assert.assertEquals("g.V().hasLabel('it\\'s;x').limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel('it\\'s;x')", LIMIT));
+        Assert.assertEquals("g.V().hasLabel(\"say \\\";\\\" ok\").limit(250)",
+                            GremlinUtil.optimizeLimit(
+                            "g.V().hasLabel(\"say \\\";\\\" ok\")", LIMIT));
+    }
+
+    @Test
+    public void testNewlineInsideStringNotSeparator() {
+        // A '\n' inside quotes must not be treated as a line separator
+        String gremlin = "g.V().hasLabel('a\nb')";
+        Assert.assertEquals("g.V().hasLabel('a\nb').limit(250)",
+                            GremlinUtil.optimizeLimit(gremlin, LIMIT));
+    }
+
+    @Test
+    public void testUnclosedQuoteNotOptimized() {
+        // Never append '.limit(N)' inside an unterminated string literal
+        String gremlin = "g.V().hasLabel('unclosed";
+        Assert.assertEquals(gremlin,
+                            GremlinUtil.optimizeLimit(gremlin, LIMIT));
+    }
+
+    @Test
+    public void testUnsupportedGroovyLiteralsAreNotOptimized() {
+        String slashy = "def x = /g.V()\\n/\ng.E()";
+        Assert.assertEquals(slashy,
+                            GremlinUtil.optimizeLimit(slashy, LIMIT));
+
+        String dollarSlashy = "def x = $/g.V()/$\ng.E()";
+        Assert.assertEquals(dollarSlashy,
+                            GremlinUtil.optimizeLimit(dollarSlashy, LIMIT));
+
+        String tripleQuoted = "def x = '''g.V()\ng.E()'''\ng.V()";
+        Assert.assertEquals(tripleQuoted,
+                            GremlinUtil.optimizeLimit(tripleQuoted, LIMIT));
+
+        String division = "def half = 4 / 2\ng.V()";
+        Assert.assertEquals(division,
+                            GremlinUtil.optimizeLimit(division, LIMIT));
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java
index 3a918e49d..e8f6f038f 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java
@@ -78,15 +78,24 @@ public class LoaderScopeControllerTest {
     @Test
     public void testFileClearUsesNestedScope() {
         FileMappingService service = Mockito.mock(FileMappingService.class);
+        JobManagerService jobService = Mockito.mock(JobManagerService.class);
+        LoadTaskService taskService = Mockito.mock(LoadTaskService.class);
         FileMapping mapping = FileMapping.builder().id(11).build();
         Mockito.when(service.listByJob("space-a", "graph-a", 7))
                .thenReturn(Collections.singletonList(mapping));
+        Mockito.when(taskService.taskListByJob(7))
+               .thenReturn(Collections.emptyList());
         FileMappingController controller = new FileMappingController();
         ReflectionTestUtils.setField(controller, "service", service);
+        ReflectionTestUtils.setField(controller, "jobService", jobService);
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
 
         controller.clear("space-a", "graph-a", 7);
 
-        Mockito.verify(service).remove(11);
+        Mockito.verify(jobService).deleteMappings(
+                7, Collections.singletonList(mapping));
+        Mockito.verify(service, Mockito.never()).deleteDiskFile(mapping);
+        Mockito.verify(service, Mockito.never()).remove(11);
         Mockito.verify(service, Mockito.never()).listAll();
     }
 
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/PriorityFixTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/PriorityFixTest.java
index 5581c410a..b30bfd4ee 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/PriorityFixTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/PriorityFixTest.java
@@ -73,6 +73,16 @@ public class PriorityFixTest {
         PageUtil.checkPositivePage(1, -1);
     }
 
+    @Test(expected = IllegalArgumentException.class)
+    public void testBoundedIntRejectsUnsupportedNegativePageSize() {
+        PageUtil.boundedSize(-2);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBoundedLongRejectsUnsupportedNegativePageSize() {
+        PageUtil.boundedSize(-2L);
+    }
+
     @Test(expected = ExternalException.class)
     public void testInterceptorRejectsOversizedPage() throws Exception {
         MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/list");
@@ -83,6 +93,16 @@ public class PriorityFixTest {
                                           new Object());
     }
 
+    @Test(expected = ExternalException.class)
+    public void testInterceptorRejectsUnsupportedNegativePageSize() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/list");
+        request.setParameter("page_size", "-2");
+
+        new CustomInterceptor().preHandle(request,
+                                          new MockHttpServletResponse(),
+                                          new Object());
+    }
+
     @Test
     public void testInterceptorKeepsLegacyAllPageSentinel() throws Exception {
         MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/list");
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
index 6d8f611db..a6b487b09 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -60,6 +60,7 @@ import org.junit.runners.Suite;
     GraphSpaceAuthOwnershipTest.class,
     GraphSpaceServiceTest.class,
     GraphsControllerCanonicalTest.class,
+    GremlinUtilTest.class,
     GremlinHistoryFailureTest.class,
     HubbleOptionsTest.class,
     IngestControllerTest.class,
diff --git a/hugegraph-hubble/hubble-dist/assembly/static/bin/start-hubble.sh b/hugegraph-hubble/hubble-dist/assembly/static/bin/start-hubble.sh
index 6c71becb7..6845fb213 100644
--- a/hugegraph-hubble/hubble-dist/assembly/static/bin/start-hubble.sh
+++ b/hugegraph-hubble/hubble-dist/assembly/static/bin/start-hubble.sh
@@ -28,6 +28,15 @@ LIB_PATH=${HOME_PATH}/lib
 LOG_PATH=${HOME_PATH}/logs
 PID_FILE=${BIN_PATH}/pid
 
+process_start_time() {
+    local process_pid=$1
+    if [[ -r /proc/${process_pid}/stat ]]; then
+        awk '{print $22}' "/proc/${process_pid}/stat"
+    else
+        LC_ALL=C ps -o lstart= -p "${process_pid}" 2>/dev/null
+    fi
+}
+
 . "${BIN_PATH}"/common_functions
 
 java_env_check
@@ -74,17 +83,36 @@ while [[ $# -gt 0 ]]; do
     shift
 done
 
+MAIN_CLASS="org.apache.hugegraph.HugeGraphHubble"
+
 if [[ -f ${PID_FILE} ]] ; then
-    PID=$(cat "${PID_FILE}")
-    if kill -0 "${PID}" > /dev/null 2>&1; then
-        echo "HugeGraphHubble is running as process ${PID}, please stop it first!"
-        exit 1
+    read -r PID PID_START < "${PID_FILE}"
+    if [[ ! ${PID} =~ ^[0-9]+$ ]]; then
+        echo "Invalid HugeGraphHubble PID file, removing it"
+        rm "${PID_FILE}"
+    elif kill -0 "${PID}" > /dev/null 2>&1; then
+        CURRENT_START=$(process_start_time "${PID}") || CURRENT_START=""
+        if [[ -z ${PID_START} ]]; then
+            PROCESS_ARGS=$(ps -p "${PID}" -o args= 2>/dev/null || true)
+            if [[ ${PROCESS_ARGS} == *"${MAIN_CLASS}"* &&
+                  ${PROCESS_ARGS} == *"-Dhubble.home.path=${HOME_PATH}"* ]]; then
+                echo "HugeGraphHubble is running as process ${PID}, please stop it first!"
+                exit 1
+            fi
+            echo "Stale HugeGraphHubble PID file, removing it"
+            rm "${PID_FILE}"
+        elif [[ ${PID_START} != "${CURRENT_START}" ]]; then
+            echo "Stale HugeGraphHubble PID file, removing it"
+            rm "${PID_FILE}"
+        else
+            echo "HugeGraphHubble is running as process ${PID}, please stop it first!"
+            exit 1
+        fi
     else
         rm "${PID_FILE}"
     fi
 fi
 
-MAIN_CLASS="org.apache.hugegraph.HugeGraphHubble"
 ARGS=${CONF_PATH}/hugegraph-hubble.properties
 LOG=${LOG_PATH}/hugegraph-hubble.log
 
@@ -99,7 +127,13 @@ else
 fi
 
 PID=$!
-echo ${PID} > "${PID_FILE}"
+PID_START=$(process_start_time "${PID}") || PID_START=""
+if ! kill -0 "${PID}" > /dev/null 2>&1; then
+    wait "${PID}" || true
+    cat "${LOG}" || true
+    exit 1
+fi
+echo "${PID} ${PID_START}" > "${PID_FILE}"
 
 # wait hubble start
 TIMEOUT_S=30
diff --git a/hugegraph-hubble/hubble-dist/assembly/static/bin/stop-hubble.sh b/hugegraph-hubble/hubble-dist/assembly/static/bin/stop-hubble.sh
index 03f1015af..8db837359 100644
--- a/hugegraph-hubble/hubble-dist/assembly/static/bin/stop-hubble.sh
+++ b/hugegraph-hubble/hubble-dist/assembly/static/bin/stop-hubble.sh
@@ -19,13 +19,77 @@ HOME_PATH=$(dirname "$0")
 HOME_PATH=$(cd "${HOME_PATH}"/.. && pwd)
 BIN_PATH=${HOME_PATH}/bin
 PID_FILE=${BIN_PATH}/pid
+# Seconds to wait for a graceful shutdown before resorting to SIGKILL
+STOP_TIMEOUT=${STOP_TIMEOUT:-30}
+
+if [[ ! ${STOP_TIMEOUT} =~ ^[0-9]+$ ]]; then
+    echo "STOP_TIMEOUT must be a non-negative integer" >&2
+    exit 1
+fi
+
+process_start_time() {
+    local process_pid=$1
+    if [[ -r /proc/${process_pid}/stat ]]; then
+        awk '{print $22}' "/proc/${process_pid}/stat"
+    else
+        LC_ALL=C ps -o lstart= -p "${process_pid}" 2>/dev/null
+    fi
+}
 
 if [[ -f ${PID_FILE} ]]; then
-    pid=$(cat "${PID_FILE}")
-    if kill -0 "${pid}" > /dev/null 2>&1; then
-        kill -9 "${pid}"
+    read -r pid expected_start < "${PID_FILE}"
+    if [[ ! ${pid} =~ ^[0-9]+$ ]]; then
+        echo "Invalid HugeGraphHubble PID file; refusing to signal a process" >&2
+        exit 1
+    fi
+
+    same_process() {
+        if ! kill -0 "${pid}" > /dev/null 2>&1; then
+            return 1
+        fi
+        if [[ -n ${expected_start} ]]; then
+            [[ $(process_start_time "${pid}") == "${expected_start}" ]]
+            return
+        fi
+        # Backward compatibility for PID files written by older launchers.
+        process_args=$(ps -p "${pid}" -o args= 2>/dev/null) || return 1
+        [[ ${process_args} == *"org.apache.hugegraph.HugeGraphHubble"* &&
+           ${process_args} == *"-Dhubble.home.path=${HOME_PATH}"* ]]
+    }
+
+    if same_process; then
+        # SIGTERM first so the Spring shutdown hooks run: they pause the
+        # running load tasks and close the H2 database cleanly. Only escalate
+        # to SIGKILL if the process is still alive after STOP_TIMEOUT.
+        kill "${pid}" > /dev/null 2>&1
+        waited=0
+        while [ "${waited}" -lt "${STOP_TIMEOUT}" ]; do
+            if ! same_process; then
+                break
+            fi
+            sleep 1
+            waited=$((waited + 1))
+        done
+        if same_process; then
+            echo "HugeGraphHubble did not exit within ${STOP_TIMEOUT}s, killing it"
+            kill -9 "${pid}" > /dev/null 2>&1
+        fi
+        for _ in 1 2 3 4 5; do
+            if ! same_process; then
+                break
+            fi
+            sleep 1
+        done
+        if same_process; then
+            echo "HugeGraphHubble process ${pid} is still alive; retaining PID file" >&2
+            exit 1
+        fi
         echo "stopped HugeGraphHubble"
     else
+        if kill -0 "${pid}" > /dev/null 2>&1; then
+            echo "PID ${pid} belongs to a different process; retaining PID file" >&2
+            exit 1
+        fi
         echo "process ${pid} not exist"
     fi
     rm "${PID_FILE}"
diff --git a/hugegraph-hubble/hubble-fe/src/api/request.js b/hugegraph-hubble/hubble-fe/src/api/request.js
index 8ce3355fc..84a534b6a 100644
--- a/hugegraph-hubble/hubble-fe/src/api/request.js
+++ b/hugegraph-hubble/hubble-fe/src/api/request.js
@@ -88,9 +88,26 @@ const notifyForbidden = config => {
     }
 };
 
+// Axios buildURL un-escapes [ and ] in query strings, but Tomcat rejects raw
+// brackets in the request target with a plain 400, so encode every character.
+const serializeParams = params => {
+    const parts = [];
+    Object.entries(params || {}).forEach(([key, value]) => {
+        if (value === undefined || value === null) {
+            return;
+        }
+        const values = Array.isArray(value) ? value : [value];
+        values.forEach(v => {
+            parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(v));
+        });
+    });
+    return parts.join('&');
+};
+
 const instance = axios.create({
     baseURL: '/api/v1.3',
     withCredentials: true,
+    paramsSerializer: serializeParams,
     // Backend times out after 30s; keep this slightly higher to receive its error body.
     timeout: 31000,
     transformResponse: [parseResponse],
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
index cea1beb88..03c799cd3 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
@@ -80,6 +80,7 @@
       "mode_2d": "2D Mode",
       "mode_3d": "3D Mode",
       "import_success": "Imported successfully",
+      "import_failed": "Import failed",
       "export_json": "Export JSON",
       "export_png": "Export image",
       "export_json_title": "Export JSON",
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
index d00fd29ed..6f55f7414 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
@@ -576,6 +576,7 @@
       "sync_type": "Sync Type",
       "status": "Status",
       "create_time": "Created At",
+      "creator": "Creator",
       "operation": "Actions"
     },
     "statistic": {
@@ -988,6 +989,7 @@
       "delete_confirm": "Confirm deleting this edge type?",
       "delete_warning": "This action cannot be undone. Please proceed with caution.",
       "delete_task_note": "Deleting metadata may take a while. Check Async Tasks for progress.",
+      "parent_hint": "Parent: {{name}}",
       "col": {
         "name": "Edge Type Name",
         "type": "Edge Type",
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
index f2117218a..13ddc2b95 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
@@ -80,6 +80,7 @@
       "mode_2d": "2D 模式",
       "mode_3d": "3D 模式",
       "import_success": "导入成功",
+      "import_failed": "导入失败",
       "export_json": "导出 JSON",
       "export_png": "导出图片",
       "export_json_title": "导出 JSON",
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
index 2737959ce..a80f7f0d7 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
@@ -576,6 +576,7 @@
       "sync_type": "同步类型",
       "status": "状态",
       "create_time": "创建时间",
+      "creator": "创建人",
       "operation": "操作"
     },
     "statistic": {
@@ -988,6 +989,7 @@
       "delete_confirm": "确认删除此边类型?",
       "delete_warning": "删除后无法恢复,请谨慎操作",
       "delete_task_note": "删除元数据耗时较久,详情可在异步任务中查看",
+      "parent_hint": "父边:{{name}}",
       "col": {
         "name": "边类型名称",
         "type": "边类型",
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
index 88cd565aa..c9fae69db 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
@@ -134,6 +134,13 @@ const AlgorithmHome = () => {
 
     const getFavoriteQueriesList = useCallback(
         async () => {
+            if (!graphSpace || !graph) {
+                favoriteQueriesRequest.current = null;
+                setFavoriteQueriesLoading(false);
+                setFavoriteQueriesError(false);
+                setFavoriteQueriesData({});
+                return;
+            }
             const request = Symbol('algorithm-favorites');
             favoriteQueriesRequest.current = request;
             const params = {
@@ -178,6 +185,13 @@ const AlgorithmHome = () => {
 
     const getExecutionLogsList = useCallback(
         async () => {
+            if (!graphSpace || !graph) {
+                executionLogsRequest.current = null;
+                setExecutionLogsLoading(false);
+                setExecutionLogsError(false);
+                setExecutionLogsData({});
+                return;
+            }
             const request = Symbol('algorithm-logs');
             executionLogsRequest.current = request;
             const params = {'page_size': pageSize, 'page_no': pageExecute, 'type': TYPE.ALGORITHM};
diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
index 326f68008..dec24f9ba 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
@@ -116,7 +116,11 @@ const AnalysisHome = () => {
 
     const getExecutionLogsList = useCallback(
         async () => {
-            if (analysisMode === TEXT2GQL) {
+            if (analysisMode === TEXT2GQL || !graphSpace || !graph) {
+                executionLogsRequest.current = null;
+                setExecutionLogsLoading(false);
+                setExecutionLogsError(false);
+                setExecutionLogsData({});
                 return;
             }
             const request = Symbol('execution-logs');
@@ -154,7 +158,11 @@ const AnalysisHome = () => {
 
     const getFavoriteQueriesList = useCallback(
         async () => {
-            if (analysisMode === TEXT2GQL) {
+            if (analysisMode === TEXT2GQL || !graphSpace || !graph) {
+                favoriteQueriesRequest.current = null;
+                setFavoriteQueriesLoading(false);
+                setFavoriteQueriesError(false);
+                setFavoriteQueriesData({});
                 return;
             }
             const request = Symbol('favorite-queries');
diff --git a/hugegraph-hubble/hubble-fe/src/modules/asyncTasks/Detail/index.js b/hugegraph-hubble/hubble-fe/src/modules/asyncTasks/Detail/index.js
index e984a7b37..dbe631a5b 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/asyncTasks/Detail/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/asyncTasks/Detail/index.js
@@ -215,7 +215,7 @@ const AsyncTaskDetail = props => {
             title: t('analysis.async_task.column.create_time'),
             dataIndex: 'task_create',
             render: (task_create, rowData, index) => {
-                const convertedDate = format(new Date(task_create), 'yyyy-MM-dd H:m:ss');
+                const convertedDate = task_create ? format(new Date(task_create), 'yyyy-MM-dd HH:mm:ss') : '--';
                 return (<>{convertedDate}</>);
             },
         },
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/DynamicAddEdge/index.js b/hugegraph-hubble/hubble-fe/src/modules/component/DynamicAddEdge/index.js
index c88205cf1..c4b7a8d40 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/DynamicAddEdge/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/DynamicAddEdge/index.js
@@ -97,8 +97,8 @@ const AddEdgeDrawer = props => {
             api.analysis.fetchEdgeLabels(currentGraphSpace, currentGraph, label)
                 .then(res => {
                     const {data, status, message: errMsg} = res;
-                    const {properties} = data;
                     if (status === 200) {
+                        const {properties} = data;
                         setEdgeProperties(properties);
                     }
                     else {
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/ImportData/index.js b/hugegraph-hubble/hubble-fe/src/modules/component/ImportData/index.js
index 6e6dbab22..a09f7b6ee 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/ImportData/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/ImportData/index.js
@@ -46,17 +46,32 @@ const ImportData = props => {
 
     const handleUploadChange = useCallback(
         info => {
-            if (info.file.status === 'uploading') {
+            const {status, response} = info.file;
+
+            if (status === 'uploading') {
                 onUploadChange(LOADING);
+                return;
+            }
+
+            // antd emits 'error' on HTTP/network failure. Without this branch the
+            // canvas stays in LOADING forever. An error page body is not JSON, so
+            // `response.message` may be missing entirely.
+            if (status === 'error') {
+                const failure = response?.message || t('analysis.canvas.import_failed');
+                onUploadChange(UPLOAD_FAILED, failure);
+                message.error(failure);
+                return;
             }
-            if (info.file.status === 'done') {
-                const {response} = info.fileList[0];
-                if (response.status === 200) {
+
+            if (status === 'done') {
+                if (response?.status === 200) {
                     onUploadChange(SUCCESS, t('analysis.canvas.import_success'), response.data);
                     message.success(t('analysis.canvas.import_success'));
                     return;
                 }
-                onUploadChange(UPLOAD_FAILED, response.message);
+
+                const failure = response?.message || t('analysis.canvas.import_failed');
+                onUploadChange(UPLOAD_FAILED, failure);
             }
         },
         [onUploadChange, t]
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.js
index 629053845..a38f23f53 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.js
@@ -18,7 +18,7 @@
 
 import {Modal, Button, Form, Input, Typography, Select,
     Divider, Upload, Radio, AutoComplete, Checkbox, message, Space} from 'antd';
-import {useState, useCallback} from 'react';
+import {useState, useCallback, useEffect, useRef} from 'react';
 import {useTranslation} from 'react-i18next';
 import * as api from '../../api';
 import * as rules from '../../utils/rules';
@@ -937,6 +937,20 @@ const EditLayer = ({edit, visible, onCancel, refresh}) => {
     const [selectedTemplate, setSelectedTemplate] = useState(undefined);
     const [loading, setLoading] = useState(false);
     const [form] = Form.useForm();
+    const requestEpoch = useRef(0);
+    const visibleRef = useRef(visible);
+    visibleRef.current = visible;
+
+    const invalidateRequests = useCallback(() => {
+        requestEpoch.current += 1;
+        setLoading(false);
+    }, []);
+
+    useEffect(() => {
+        if (!visible) {
+            invalidateRequests();
+        }
+    }, [invalidateRequests, visible]);
 
     const sourceTypeOptions = [
         {label: 'HDFS', value: 'HDFS'},
@@ -950,30 +964,59 @@ const EditLayer = ({edit, visible, onCancel, refresh}) => {
             return;
         }
 
+        const session = requestEpoch.current;
         form.validateFields().then(values => {
+            if (!visibleRef.current || session !== requestEpoch.current) {
+                return;
+            }
             const formatData = formatDatasource(values);
-            // return;
+            const request = ++requestEpoch.current;
             setLoading(true);
+            // Every active exit clears `loading`; callbacks from an invalidated
+            // modal session are ignored so they cannot affect a reopened form.
             api.manage.addDatasource(formatData).then(res => {
+                if (!visibleRef.current || request !== requestEpoch.current) {
+                    return;
+                }
                 setLoading(false);
 
-                if (res.status === 200) {
+                if (res?.status === 200) {
                     message.success(t('common.msg.create_success'));
+                    requestEpoch.current += 1;
                     onCancel();
                     refresh();
                     return;
                 }
 
-                message.error(res.message);
+                message.error(res?.message || t('common.msg.operation_failed'));
+            }).catch(() => {
+                if (!visibleRef.current || request !== requestEpoch.current) {
+                    return;
+                }
+                setLoading(false);
+                message.error(t('common.msg.operation_failed'));
             });
+        }).catch(error => {
+            if (!visibleRef.current || session !== requestEpoch.current) {
+                return;
+            }
+            if (!error?.errorFields) {
+                message.error(t('common.msg.operation_failed'));
+            }
         });
     }, [form, loading, onCancel, refresh, t]);
 
+    const handleCancel = useCallback(() => {
+        invalidateRequests();
+        onCancel();
+    }, [invalidateRequests, onCancel]);
+
     const handleClose = useCallback(() => {
+        invalidateRequests();
         form.resetFields();
         setSourceType('');
         setSelectedTemplate(undefined);
-    }, [form]);
+    }, [form, invalidateRequests]);
 
     const handleType = useCallback(val => {
         form.resetFields(SOURCE_CONFIG_FIELDS);
@@ -989,7 +1032,7 @@ const EditLayer = ({edit, visible, onCancel, refresh}) => {
     return (
         <Modal
             title={edit ? t('datasource.form.title_edit') : t('datasource.form.title_create')}
-            onCancel={onCancel}
+            onCancel={handleCancel}
             open={visible}
             width={600}
             onOk={onFinish}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.test.js b/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.test.js
index b5cae6c0e..4ce5fef33 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Datasource/EditLayer.test.js
@@ -16,7 +16,7 @@
  */
 
 import {useCallback, useState} from 'react';
-import {fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import EditLayer from './EditLayer';
 
@@ -52,6 +52,7 @@ const ReopenableEditLayer = ({refresh = jest.fn()}) => {
     return (
         <>
             <button onClick={open}>reopen datasource</button>
+            <button onClick={close}>close datasource externally</button>
             <EditLayer
                 visible={visible}
                 edit={false}
@@ -68,6 +69,16 @@ const selectTemplate = async label => {
     await userEvent.click(await screen.findByText(label));
 };
 
+const deferred = () => {
+    let resolve;
+    let reject;
+    const promise = new Promise((onResolve, onReject) => {
+        resolve = onResolve;
+        reject = onReject;
+    });
+    return {promise, resolve, reject};
+};
+
 const expectFreshForm = () => {
     expect(screen.getByPlaceholderText('datasource.form.name_placeholder')).toHaveValue('');
     expect(screen.queryByText('FILE', {selector: '.ant-select-selection-item'}))
@@ -144,3 +155,73 @@ test('clears template, type, and hidden config after successful create and reope
     await userEvent.click(screen.getByRole('button', {name: 'reopen datasource'}));
     expectFreshForm();
 });
+
+test('ignores a create response from a closed modal session', async () => {
+    const api = jest.requireMock('../../api');
+    const first = deferred();
+    const second = deferred();
+    const refresh = jest.fn();
+    api.manage.addDatasource.mockReset();
+    api.manage.addDatasource
+        .mockReturnValueOnce(first.promise)
+        .mockReturnValueOnce(second.promise);
+    render(<ReopenableEditLayer refresh={refresh} />);
+
+    await selectTemplate('datasource.form.templates.jdbc_mysql');
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.url_placeholder'), {
+        target: {value: 'jdbc:mysql://127.0.0.1:3306/first'},
+    });
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.password_placeholder'), {
+        target: {value: 'first-secret'},
+    });
+    await userEvent.click(screen.getByRole('button', {name: 'OK'}));
+    await waitFor(() => expect(api.manage.addDatasource).toHaveBeenCalledTimes(1));
+
+    await userEvent.click(screen.getByRole('button', {name: 'Cancel'}));
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+    await userEvent.click(screen.getByRole('button', {name: 'reopen datasource'}));
+    await selectTemplate('datasource.form.templates.jdbc_mysql');
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.url_placeholder'), {
+        target: {value: 'jdbc:mysql://127.0.0.1:3306/second'},
+    });
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.password_placeholder'), {
+        target: {value: 'second-secret'},
+    });
+    await userEvent.click(screen.getByRole('button', {name: 'OK'}));
+    await waitFor(() => expect(api.manage.addDatasource).toHaveBeenCalledTimes(2));
+
+    await act(async () => first.resolve({status: 200}));
+    expect(screen.getByRole('dialog')).toBeInTheDocument();
+    expect(refresh).not.toHaveBeenCalled();
+
+    await act(async () => second.resolve({status: 200}));
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+    expect(refresh).toHaveBeenCalledTimes(1);
+});
+
+test('ignores a create response after prop-driven modal closure', async () => {
+    const api = jest.requireMock('../../api');
+    const request = deferred();
+    const refresh = jest.fn();
+    api.manage.addDatasource.mockReset();
+    api.manage.addDatasource.mockReturnValueOnce(request.promise);
+    render(<ReopenableEditLayer refresh={refresh} />);
+
+    await selectTemplate('datasource.form.templates.jdbc_mysql');
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.url_placeholder'), {
+        target: {value: 'jdbc:mysql://127.0.0.1:3306/closed'},
+    });
+    fireEvent.change(screen.getByPlaceholderText('datasource.form.password_placeholder'), {
+        target: {value: 'temporary-secret'},
+    });
+    await userEvent.click(screen.getByRole('button', {name: 'OK'}));
+    await waitFor(() => expect(api.manage.addDatasource).toHaveBeenCalledTimes(1));
+
+    await userEvent.click(screen.getByRole('button', {
+        name: 'close datasource externally',
+    }));
+    await act(async () => request.resolve({status: 200}));
+
+    expect(refresh).not.toHaveBeenCalled();
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js
index 4c8fcb70a..a07696bfa 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js
@@ -351,14 +351,14 @@ const Graph = () => {
             dataIndex: 'create_time',
             align: 'center',
             width: 140,
-            render: val => moment(val).format('YYYY-MM-DD'),
+            render: val => (val ? moment(val).format('YYYY-MM-DD') : '--'),
         },
         {
             title: t('graph.detail.update_data'),
             dataIndex: 'update_time',
             align: 'center',
             width: 140,
-            render: val => moment(val).format('YYYY-MM-DD'),
+            render: val => (val ? moment(val).format('YYYY-MM-DD') : '--'),
         },
         {
             title: t('graph.col.creator'),
@@ -615,6 +615,7 @@ const Graph = () => {
                                 columns={columns}
                                 dataSource={data}
                                 rowKey='name'
+                                scroll={{x: 'max-content'}}
                                 locale={{emptyText: listUnavailable ? null : emptyState}}
                                 pagination={pagination}
                                 onChange={handleTable}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.module.scss b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.module.scss
index 455a63f48..6c5c6df8f 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.module.scss
+++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.module.scss
@@ -70,7 +70,11 @@ div.add_card {
 
 .element_counts {
     display: inline-block;
+    max-width: 100%;
+    overflow: hidden;
     white-space: nowrap;
+    text-overflow: ellipsis;
+    vertical-align: middle;
     color: #667085;
     cursor: default;
 }
diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
index 6b369231e..f4db39cd6 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
@@ -55,7 +55,7 @@ const TitleField = ({item, onClick, onKeyDown}) => {
                 {item.default && (
                     <span className={style.default}>{t('graphspace.card.set_default')}</span>
                 )}
-                {moment(item.create_time).format('YYYY-MM-DD')} {t('graphspace.card.created')}
+                {item.create_time ? moment(item.create_time).format('YYYY-MM-DD') : '--'} {t('graphspace.card.created')}
             </div>
         </>
     );
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Meta/Edge/index.js b/hugegraph-hubble/hubble-fe/src/pages/Meta/Edge/index.js
index e4749510f..873817c85 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Meta/Edge/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Meta/Edge/index.js
@@ -169,10 +169,10 @@ const EdgeTable = () => {
     ];
 
     useEffect(() => {
-        api.manage.getMetaPropertyList(graphspace, graph).then(res => {
+        api.manage.getMetaPropertyList(graphspace, graph, {page_size: -1}).then(res => {
             if (res.status === 200) {
                 setPropertyList(res.data.records.map(item => ({
-                    lable: item.name,
+                    label: item.name,
                     value: item.name,
                     data_type: item.data_type,
                 })));
@@ -215,6 +215,7 @@ const EdgeTable = () => {
                 rowKey={rowKey}
                 showExpandColumn={false}
                 loading={loading}
+                scroll={{x: 'max-content'}}
             />
 
             <EditEdgeLayer
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Meta/Vertex/index.js b/hugegraph-hubble/hubble-fe/src/pages/Meta/Vertex/index.js
index fe588d5ec..e7fd924ec 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Meta/Vertex/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Meta/Vertex/index.js
@@ -172,7 +172,7 @@ const VertexTable = () => {
         api.manage.getMetaPropertyList(graphspace, graph, {page_size: -1}).then(res => {
             if (res.status === 200) {
                 setPropertyList(res.data.records.map(item => ({
-                    lable: item.name,
+                    label: item.name,
                     value: item.name,
                     data_type: item.data_type,
                 })));
@@ -204,6 +204,7 @@ const VertexTable = () => {
                 onChange={handleTable}
                 rowKey={rowKey}
                 loading={loading}
+                scroll={{x: 'max-content'}}
             />
 
             <EditVertexLayer
diff --git a/hugegraph-hubble/hubble-fe/src/pages/My/index.js b/hugegraph-hubble/hubble-fe/src/pages/My/index.js
index 90e7b26bc..e3ac4896a 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/My/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/My/index.js
@@ -244,7 +244,11 @@ const My = () => {
                                     <Form.Item label={t('my.col.name')} name='user_name'>
                                         <Input disabled />
                                     </Form.Item>
-                                    <Form.Item label={t('my.edit.old_password')} name='old_password'>
+                                    <Form.Item
+                                        label={t('my.edit.old_password')}
+                                        name='old_password'
+                                        rules={[rules.required()]}
+                                    >
                                         <Input.Password
                                             autoComplete='new-password'
                                             placeholder={t('my.edit.old_password_placeholder')}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
index 51856efa1..a9b35ee9d 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
@@ -192,7 +192,9 @@ const Nodes = () => {
     const changeTable = useCallback((pagination, filters, sorter) => update({
         page: String(pagination.current),
         page_size: String(pagination.pageSize),
-        sort: sorter.field || 'name',
+        // Columns without a dataIndex (observed_at) leave sorter.field undefined,
+        // so fall back to the column key that sortOrder() also matches on.
+        sort: (sorter.columnKey ?? sorter.field) || 'name',
         order: sorter.order === 'descend' ? 'desc' : 'asc',
     }), [update]);
     const sortOrder = field => {
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Task/components/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Task/components/EditLayer.js
index ebb37984f..3886841d8 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Task/components/EditLayer.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Task/components/EditLayer.js
@@ -23,7 +23,7 @@ import {
     Radio,
     message,
 } from 'antd';
-import {useEffect, useState, useCallback} from 'react';
+import {useEffect, useState, useCallback, useRef} from 'react';
 import {useTranslation} from 'react-i18next';
 import * as rules from '../../../utils/rules';
 import * as api from '../../../api';
@@ -32,6 +32,8 @@ const EditLayer = ({visible, onCancel, data, refresh}) => {
     const {t} = useTranslation();
     const [form] = Form.useForm();
     const [syncType, setSyncType] = useState('');
+    const [loading, setLoading] = useState(false);
+    const updateRequest = useRef(null);
     const datasourceType = data?.ingestion_mapping?.structs[0]?.input?.type;
     const scheduleOptions = datasourceType === 'KAFKA'
         ? [{label: t('task.sync.realtime'), value: 'REALTIME'}]
@@ -51,20 +53,44 @@ const EditLayer = ({visible, onCancel, data, refresh}) => {
                 delete values.task_schedule_extend;
             }
 
+            const request = Symbol('task-update');
+            updateRequest.current = request;
+            setLoading(true);
+            // The OK spinner must clear on every outcome, including a rejected
+            // request, otherwise the button stays locked with the modal open.
             api.manage.updateTask(data.task_id, values).then(res => {
-                if (res.status === 200) {
+                if (updateRequest.current !== request) {
+                    return;
+                }
+                updateRequest.current = null;
+                setLoading(false);
+
+                if (res?.status === 200) {
                     message.success(t('task.edit.update_success'));
                     onCancel();
                     refresh();
                     return;
                 }
 
-                message.error(res.message);
+                message.error(res?.message || t('common.msg.operation_failed'));
+            }).catch(() => {
+                if (updateRequest.current !== request) {
+                    return;
+                }
+                updateRequest.current = null;
+                setLoading(false);
+                message.error(t('common.msg.operation_failed'));
             });
+        }).catch(error => {
+            if (!error?.errorFields) {
+                message.error(t('common.msg.operation_failed'));
+            }
         });
     }, [data.task_id, form, onCancel, refresh, syncType, t]);
 
     useEffect(() => {
+        updateRequest.current = null;
+        setLoading(false);
         if (!visible) {
             return;
         }
@@ -78,14 +104,24 @@ const EditLayer = ({visible, onCancel, data, refresh}) => {
             message.error(res.message);
         });
 
+        return () => {
+            updateRequest.current = null;
+        };
     }, [visible, data.task_id, form]);
 
+    const handleCancel = useCallback(() => {
+        updateRequest.current = null;
+        setLoading(false);
+        onCancel();
+    }, [onCancel]);
+
     return (
         <Modal
             title={t('task.edit_title_edit')}
-            onCancel={onCancel}
+            onCancel={handleCancel}
             open={visible}
             onOk={onFinish}
+            confirmLoading={loading}
             destroyOnClose
         >
             <Form
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Task/index.js b/hugegraph-hubble/hubble-fe/src/pages/Task/index.js
index f1ffe0bc7..db43435cc 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Task/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Task/index.js
@@ -260,13 +260,17 @@ const Task = () => {
         setLoading(true);
         api.manage.deleteTask(id).then(res => {
             setLoading(false);
-            if (res.status === 200) {
+
+            if (res?.status === 200) {
                 setRefresh(!refresh);
                 message.success(t('common.msg.delete_success'));
 
                 return;
             }
 
+            message.error(t('common.msg.delete_fail'));
+        }).catch(() => {
+            setLoading(false);
             message.error(t('common.msg.delete_fail'));
         });
     }, [refresh, t]);
@@ -382,19 +386,19 @@ const Task = () => {
         {
             title: t('task.col.target_space'),
             dataIndex: 'ingestion_option',
-            render: val => val.graphspace,
+            render: val => val?.graphspace ?? '-',
         },
         {
             title: t('task.col.target_graph'),
             dataIndex: 'ingestion_option',
-            render: val => val.graph,
+            render: val => val?.graph ?? '-',
         },
         {
             title: t('task.col.create_time'),
             dataIndex: 'create_time',
         },
         {
-            title: t('account.col.id'),
+            title: t('task.col.creator'),
             dataIndex: 'creator',
         },
         {
@@ -617,6 +621,7 @@ const Task = () => {
                     <Table
                         columns={columns}
                         rowKey={rowKey}
+                        scroll={{x: 'max-content'}}
                         dataSource={data}
                         pagination={pagination}
                         onChange={handleTable}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskDetail/index.js b/hugegraph-hubble/hubble-fe/src/pages/TaskDetail/index.js
index 39ca3865e..1bf23d70f 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskDetail/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskDetail/index.js
@@ -28,7 +28,7 @@ const createColumns = t => [
     {
         title: t('task.detail.job_id'),
         dataIndex: 'job_id',
-        render: val => val.toString(),
+        render: val => (val === null || val === undefined ? '-' : val.toString()),
     },
     {
         title: t('task.detail.import_count'),
@@ -81,6 +81,8 @@ const createColumns = t => [
     },
 ];
 
+const getRowKey = record => record.id ?? record.job_id;
+
 const TaskDetail = () => {
     const {t} = useTranslation();
     const columns = createColumns(t);
@@ -161,9 +163,10 @@ const TaskDetail = () => {
                 )}
                 <Spin spinning={loading}>
                     <Table
-                        rowKey='job_id'
+                        rowKey={getRowKey}
                         columns={columns}
                         dataSource={visibleData}
+                        scroll={{x: 'max-content'}}
                     />
                 </Spin>
             </div>
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
index 0277f9c93..c1357c3db 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
@@ -84,12 +84,21 @@ const BaseForm = ({cancel, visible, loading}) => {
 
     const checkExistName = () => ({
         validator: async (_, value) => {
-            const res = await api.manage.getTaskList({query: value}).then(res => {
+            const res = await api.manage.getTaskList({
+                query: value,
+                // `query` is a substring match, so the exact name can fall outside
+                // the default first page of 10 substring hits.
+                page_size: -1,
+            }).then(res => {
                 if (res.status !== 200) {
                     return t('task.edit.duplicate_check_failed');
                 }
 
-                if (res.data.total > 0) {
+                // The list endpoint treats `query` as a LIKE '%value%' match, so a
+                // non-zero total only means some name contains this one. Compare the
+                // returned names exactly before rejecting.
+                const records = res.data?.records ?? [];
+                if (records.some(item => item.task_name === value)) {
                     return t('task.edit.duplicate_name');
                 }
 
diff --git a/hugegraph-hubble/hubble-fe/src/utils/formatTimeDuration.js b/hugegraph-hubble/hubble-fe/src/utils/formatTimeDuration.js
index bcfdfc00c..6fe7a52ab 100644
--- a/hugegraph-hubble/hubble-fe/src/utils/formatTimeDuration.js
+++ b/hugegraph-hubble/hubble-fe/src/utils/formatTimeDuration.js
@@ -27,10 +27,16 @@ import _ from 'lodash';
 
 export default function formatTimeDuration(start, end) {
     const duration = intervalToDuration({start, end});
-    const {days, hours, minutes, seconds} = duration || {};
-    const preFormatedDuration = formatDuration(duration, {format: ['days', 'hours', 'minutes', 'seconds']});
+    const preFormatedDuration = formatDuration(
+        duration,
+        {format: ['years', 'months', 'days', 'hours', 'minutes', 'seconds']}
+    );
 
     const replaceMap = {
+        ' years': 'y',
+        ' year': 'y',
+        ' months': 'mo',
+        ' month': 'mo',
         ' days': 'd',
         ' day': 'd',
         ' hours': 'h',
@@ -47,8 +53,7 @@ export default function formatTimeDuration(start, end) {
             formatedDuration = _.replace(formatedDuration, key, replaceMap[key]);
         }
     );
-    const restMilliSecond = end - start - seconds * 1000 - minutes * 1000 * 60
-    - hours * 1000 * 60 * 60 - days * 1000 * 60 * 60 * 24;
+    const restMilliSecond = (end - start) % 1000;
 
     formatedDuration = formatedDuration + ` ${restMilliSecond}ms`;
     return formatedDuration;