This is an automated email from the ASF dual-hosted git repository.
yujun777 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new ac78bd1cdae [fix](insert) Finish or fail a cancelled INSERT OVERWRITE
instead of reporting success (#68662)
ac78bd1cdae is described below
commit ac78bd1cdae01d943693071c0245b71308fb9567
Author: yujun <[email protected]>
AuthorDate: Sat Oct 10 11:34:08 2026 +0800
[fix](insert) Finish or fail a cancelled INSERT OVERWRITE instead of
reporting success (#68662)
An `INSERT OVERWRITE` is two-phase: the rows are committed into
temporary partitions first, and a later swap publishes them. A
cancellation that lands between the two halves cannot take anything back
-- the rows are durable, and everything the read consumed, the base
table stream offsets among it, was committed with that same transaction
(`InsertIntoTableCommand` hangs the offsets on the transaction,
`DatabaseTransactionMgr.updateCatalogAfterCommitted` applies them on
commit and replays them). The command used to answer every cancellation
in that window by dropping the temporary partitions and returning
normally, which published nothing while telling the client the overwrite
had succeeded, and left the offsets advanced past rows no target holds
-- so a re-run reads from the advanced offset and that range is silently
missing. That is the general shape of `INSERT OVERWRITE t SELECT ...
FROM stream(...)`, and also of an IVM partition refresh, which resets
the offsets of exactly the partitions it replaces. Cancellations that
landed before the rows were committed were answered the same way:
success for a statement that did nothing.
Fix: decide by whether the insert committed anything.
- If it did not -- a cancellation before the insert ran, or one landing
after an insert that took the path which begins no transaction, because
its plan folded to an empty relation, so the temporary partitions hold
no rows and there is no offset to keep -- nothing is durable, so the
statement fails, like the cancellation the inner insert already reports
for itself, and a re-run reads the same rows.
- If it did, the cancellation is too late: the swap runs, the offset
advance and the publication stay aligned, and the success the statement
reports is the outcome it is. The same decision covers an error response
over committed rows, i.e. a publication that timed out after the commit,
which `insert_visible_timeout_return_mode=error` reports as an error:
the rows exist, so they are published and the client keeps the error.
The decision is taken where the window is, and once more under the
target table's write lock -- the wait for that lock is where a
cancellation would otherwise slip through unseen. It applies to both
routes: the explicit-partition one, and `PARTITION(*)`, whose empty-plan
shape reaches the same no-transaction path when
`enable_strict_consistency_dml` is off. A target dropped under the swap
fails the statement now, instead of acknowledging an overwrite that
never happened.
Three debug points inject a cancellation at each point of the window
(`cancelBeforeTheInsertOfAnOverwrite`,
`cancelBetweenTheTwoHalvesOfAnOverwrite`,
`cancelWhileTheSwapWaitsForTheTableLock`), each scoped by table name, in
the same shape as the existing `failBetweenTheTwoHalvesOfAnOverwrite`.
Scope: this closes the cancellation and the error response, the two
entries into the window that report something other than what happened
to the rows; #68390's per-partition rebuild requirement, raised before a
refresh reads, covers the failure and master-switch halves. Not fixed
here, and tracked in #68679: the FE stopping between the commit and the
swap, a `replacePartition` that throws, a remote commit whose reply is
lost, and an ambiguous cloud commit. All of those need the publication
to become a committed action of the insert transaction (or the
consumption position to move with the publication), rather than another
check in this command.
### Release note
`KILL`/cancellation of an `INSERT OVERWRITE` that lands after its rows
were committed now completes the overwrite instead of silently reporting
a success that published nothing. One that finds nothing committed --
including an overwrite whose plan folded to an empty relation -- reports
an error and leaves the target untouched. An error response over
committed rows, i.e. a publication timeout in error mode, now publishes
them instead of rolling them back. An overwrite whose target is dropped
before its swap fails instead of reporting success.
---
.../AbstractInsertOverwriteManager.java | 13 ++
.../insertoverwrite/InsertOverwriteManager.java | 25 +++
.../RemoteInsertOverwriteManager.java | 13 ++
.../commands/insert/AbstractInsertExecutor.java | 11 +
.../insert/BaseExternalTableInsertExecutor.java | 1 +
.../commands/insert/InsertCommandContext.java | 21 ++
.../commands/insert/InsertIntoTableCommand.java | 4 +
.../insert/InsertOverwriteTableCommand.java | 217 +++++++++++++++---
.../plans/commands/insert/OlapInsertExecutor.java | 4 +
.../commands/insert/RemoteOlapInsertExecutor.java | 1 +
.../insert/InsertOverwriteDroppedTargetTest.java | 73 ++++++
.../test_insert_overwrite_cancel.out | 24 ++
.../test_insert_overwrite_cancel.groovy | 246 +++++++++++++++++++++
13 files changed, 627 insertions(+), 26 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/AbstractInsertOverwriteManager.java
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/AbstractInsertOverwriteManager.java
index 50321927fc5..04088d48fb6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/AbstractInsertOverwriteManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/AbstractInsertOverwriteManager.java
@@ -33,6 +33,19 @@ public interface AbstractInsertOverwriteManager {
void taskGroupSuccess(long groupId, OlapTable targetTable, boolean
forceDropPartition) throws DdlException;
+ /**
+ * The replacement half of {@link #taskGroupSuccess}: makes every
replacement the group recorded visible.
+ * A caller that publishes the group itself calls this and then {@link
#finishTaskGroup}, so that the task
+ * bookkeeping -- an edit-log write per task, each waiting for its journal
-- runs outside whatever lock the
+ * caller holds the replacement under. A manager whose owning frontend
runs the whole sequence behind one
+ * call replaces there and finishes there.
+ */
+ void replacePartitionsOfTaskGroup(long groupId, OlapTable targetTable,
boolean forceDropPartition)
+ throws DdlException;
+
+ /** The bookkeeping half of {@link #taskGroupSuccess}, after the
replacement it follows. */
+ void finishTaskGroup(long groupId);
+
void taskSuccess(long taskId) throws Exception;
void taskGroupFail(long groupId) throws Exception;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/InsertOverwriteManager.java
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/InsertOverwriteManager.java
index 3f0f7feff41..9863a6fdfca 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/InsertOverwriteManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/InsertOverwriteManager.java
@@ -191,6 +191,21 @@ public class InsertOverwriteManager extends MasterDaemon
implements Writable, Ab
// here we will make all raplacement of this group visiable. if someone
fails, nothing happen.
@Override
public void taskGroupSuccess(long groupId, OlapTable targetTable, boolean
forceDropPartition) throws DdlException {
+ replacePartitionsOfTaskGroup(groupId, targetTable, forceDropPartition);
+ finishTaskGroup(groupId);
+ }
+
+ /**
+ * The replacement half of {@link #taskGroupSuccess}: makes every
replacement this group recorded visible.
+ *
+ * <p>Separate from the bookkeeping that follows it because the caller
holds the target table's write lock
+ * for the last cancellation check before it publishes: the bookkeeping
writes an edit-log entry per task,
+ * each of which waits for its journal to be acknowledged, and readers and
writers of the target must not
+ * stay blocked through those waits -- the utility's own replacement,
which the lock is about, does not.
+ */
+ @Override
+ public void replacePartitionsOfTaskGroup(long groupId, OlapTable
targetTable, boolean forceDropPartition)
+ throws DdlException {
try {
Map<Long, Long> relations = partitionPairs.get(groupId);
ArrayList<String> oldNames = new ArrayList<>();
@@ -205,6 +220,16 @@ public class InsertOverwriteManager extends MasterDaemon
implements Writable, Ab
+ "all new partition will not be visible and will be
recycled by partition GC.");
throw e;
}
+ }
+
+ /**
+ * The bookkeeping half of {@link #taskGroupSuccess}: makes the group's
tasks final and forgets the group,
+ * after the replacement has been made. See
+ * {@link #replacePartitionsOfTaskGroup} for why it is not the
replacement's caller to hold this under a
+ * table lock.
+ */
+ @Override
+ public void finishTaskGroup(long groupId) {
LOG.info("insert overwrite auto detect partition task group [" +
groupId + "] succeed");
for (Long taskId : taskGroups.get(groupId)) {
Env.getCurrentEnv().getEditLog()
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/RemoteInsertOverwriteManager.java
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/RemoteInsertOverwriteManager.java
index 4e21501c1ee..65e3f9977fd 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/RemoteInsertOverwriteManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/insertoverwrite/RemoteInsertOverwriteManager.java
@@ -63,6 +63,19 @@ public class RemoteInsertOverwriteManager implements
AbstractInsertOverwriteMana
targetTable.getDatabase().getFullName(),
targetTable.getName(), groupId, forceDropPartition);
}
+ @Override
+ public void replacePartitionsOfTaskGroup(long groupId, OlapTable
targetTable, boolean forceDropPartition)
+ throws DdlException {
+ // The owning frontend runs the replacement and the bookkeeping behind
this one call, so the caller
+ // does not hold its lock across them; finishTaskGroup has nothing
left to do here.
+ taskGroupSuccess(groupId, targetTable, forceDropPartition);
+ }
+
+ @Override
+ public void finishTaskGroup(long groupId) {
+ // Finished on the owning frontend, which the replacement call reached.
+ }
+
@Override
public void taskSuccess(long taskId) throws Exception {
catalog.getFeServiceClient().taskSuccess(taskId);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
index bc501b5816b..78d6375aed3 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
@@ -355,6 +355,17 @@ public abstract class AbstractInsertExecutor {
return !emptyInsert || !streamUpdateInfos.isEmpty();
}
+ /**
+ * Records on the insert context that what this insert wrote is durable:
its transaction has committed,
+ * whether or not its publication has finished. Each executor calls this
where its own commit happens,
+ * because the response the client gets is a different question -- a
publication timeout that follows a
+ * commit is reported as an error while the rows are committed. See
+ * {@link InsertCommandContext#setCommitted}.
+ */
+ protected void markCommitted() {
+ insertCtx.ifPresent(insertCommandContext ->
insertCommandContext.setCommitted(true));
+ }
+
public void setStreamUpdateInfos(List<TableStreamUpdateInfo>
streamUpdateInfos) {
this.streamUpdateInfos = streamUpdateInfos;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
index 11d9dd8ee8d..b13027e99ff 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
@@ -122,6 +122,7 @@ public abstract class BaseExternalTableInsertExecutor
extends AbstractInsertExec
transactionManager.commit(txnId);
}
txnStatus = TransactionStatus.COMMITTED;
+ markCommitted();
long t2 = System.currentTimeMillis();
try {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertCommandContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertCommandContext.java
index 1b4e28117bc..41dce99761f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertCommandContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertCommandContext.java
@@ -23,4 +23,25 @@ package org.apache.doris.nereids.trees.plans.commands.insert;
*/
public abstract class InsertCommandContext {
+ /**
+ * Set when the insert's transaction has committed: what it wrote -- its
rows, and any table stream offset
+ * update it carried -- is durable. That is not the same as the insert
having succeeded. A load whose
+ * publication times out after its commit is committed, while the
session's visibility-timeout mode reports
+ * it to the client as an error; and a load whose plan folded to an empty
relation commits nothing at all,
+ * since it runs no transaction unless it has an offset to commit.
+ *
+ * <p>Whoever owns a transaction records it where that commit happens; see
+ * {@code AbstractInsertExecutor#markCommitted()}. A caller that owns what
happens next -- an overwrite
+ * publishing its temporary partitions -- decides by it: what is durable
has to be published, and where
+ * nothing is durable a failure or a cancellation still has everything to
take back.
+ */
+ private boolean committed = false;
+
+ public boolean hasCommitted() {
+ return committed;
+ }
+
+ public void setCommitted(boolean committed) {
+ this.committed = committed;
+ }
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
index 3606ea72526..a8c23b27e5f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
@@ -745,6 +745,10 @@ public class InsertIntoTableCommand extends Command
AbstractInsertExecutor insertExecutor = initPlan(ctx, executor);
// An empty Table Stream read still needs to commit its offset update
atomically.
if (!insertExecutor.requiresTransaction()) {
+ // Nothing is committed on this path, so a caller that asks
whether the rows are durable is told
+ // they are not -- no row and no offset was written, which is what
it needs before treating a
+ // cancellation or a later failure here as too late to take back.
+ // See InsertCommandContext#setCommitted.
return;
}
if (insertExecutorListener != null) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java
index 4fb6604d930..d6575f407bb 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java
@@ -107,6 +107,52 @@ public class InsertOverwriteTableCommand extends Command
public static final String
DEBUG_POINT_FAIL_BETWEEN_THE_HALVES_OF_AN_OVERWRITE =
"InsertOverwriteTableCommand.failBetweenTheTwoHalvesOfAnOverwrite";
+ /**
+ * Cancels the overwrite before it has committed anything, so that the
half of a cancellation's meaning
+ * that takes the statement back can be pinned by a test: nothing durable
happened, so the statement has
+ * to fail rather than report the success of an overwrite that did not
run. See test_insert_overwrite_cancel.
+ *
+ * <p>Its {@code table_name} parameter names the one table the point may
disturb: the read below answers
+ * with the default when the point is not enabled or carries no such
parameter, and no table is named by an
+ * empty string, so an enabled point cannot disturb an overwrite that is
not the one under test.
+ */
+ public static final String
DEBUG_POINT_CANCEL_BEFORE_THE_INSERT_OF_AN_OVERWRITE =
+ "InsertOverwriteTableCommand.cancelBeforeTheInsertOfAnOverwrite";
+
+ /**
+ * Cancels the overwrite in the window between its two halves -- after the
insert, before the swap -- so
+ * that the other half of a cancellation's meaning can be pinned: where
the rows are durable, the swap runs
+ * and the statement reports the success it is; where the insert committed
nothing, the cancellation still
+ * has everything to take back. See test_insert_overwrite_cancel.
+ *
+ * <p>Separate from the point above rather than one point with a stage
parameter: a point is consumed by
+ * the first lookup that reads it (see {@code
DebugPointUtil#getDebugPoint}), so a shared name would let
+ * the check at one site spend the other site's allowance and make an
armed point silently not fire.
+ */
+ public static final String
DEBUG_POINT_CANCEL_BETWEEN_THE_HALVES_OF_AN_OVERWRITE =
+
"InsertOverwriteTableCommand.cancelBetweenTheTwoHalvesOfAnOverwrite";
+
+ /**
+ * Cancels the overwrite while the swap holds the target table's write
lock, which is the window a
+ * cancellation can reach only after the check that reads the flag before
the swap was issued: the swap
+ * waits for that lock, and the wait can be as long as whoever holds it. A
cancellation with nothing
+ * committed is still honoured there, because there is nothing durable to
publish and refusing costs the
+ * statement and nothing else. See test_insert_overwrite_cancel.
+ */
+ public static final String
DEBUG_POINT_CANCEL_WHILE_THE_SWAP_WAITS_FOR_THE_TABLE_LOCK =
+
"InsertOverwriteTableCommand.cancelWhileTheSwapWaitsForTheTableLock";
+
+ /**
+ * The swap that publishes an overwrite: replacing the temp partitions for
an explicit-partition
+ * overwrite, or making a task group's replacements visible for an
auto-detect one. Both run through
+ * {@link #publishTheOverwrite}, which holds the target table's write lock
and takes the last look at the
+ * cancellation flag before letting one run.
+ */
+ @FunctionalInterface
+ interface OverwritePublication {
+ void publish() throws UserException;
+ }
+
private static final Logger LOG =
LogManager.getLogger(InsertOverwriteTableCommand.class);
private LogicalPlan originLogicalQuery;
@@ -253,45 +299,77 @@ public class InsertOverwriteTableCommand extends Command
}
if (isAutoDetectOverwrite(getLogicalQuery())) {
// taskId here is a group id. it contains all replace tasks
made and registered in rpc process.
- taskId = insertOverwriteManager.registerTaskGroup(targetTable);
+ final long groupId =
insertOverwriteManager.registerTaskGroup(targetTable);
+ taskId = groupId;
// When inserting, BE will call to replace partition by
FrontendService. FE will register new temp
// partitions and return. for transactional, the replacement
will really occur when insert successed,
// i.e. `insertInto` finished. then we call taskGroupSuccess
to make replacement.
- insertIntoAutoDetect(ctx, executor, taskId);
- insertOverwriteManager.taskGroupSuccess(taskId, (OlapTable)
targetTable, isForceDropPartition());
+ InsertCommandContext insertCtx = insertIntoAutoDetect(ctx,
executor, groupId);
+ if (isCancelled.get() && !insertCtx.hasCommitted()) {
+ // The load committed nothing -- an empty plan takes the
path that begins no transaction --
+ // so the cancellation still has everything to take back:
the catch drops the group's temp
+ // partitions (an empty plan registers none), and the
statement fails rather than reporting
+ // a replacement the client cancelled. A cancellation
landing after this check, while the
+ // swap waits for the table lock, is taken up again by
publishTheOverwrite below.
+ throw cancelledBeforeTheRowsWereCommitted("after a load
that committed nothing", ctx);
+ }
+ // The replacement stays under the lock publishTheOverwrite
takes, and the group's bookkeeping
+ // follows it: its edit-log writes wait for their journals,
and the target's readers and
+ // writers must not be blocked through them.
+ publishTheOverwrite(targetTable, insertCtx, ctx,
+ () ->
insertOverwriteManager.replacePartitionsOfTaskGroup(groupId, (OlapTable)
targetTable,
+ isForceDropPartition()));
+ insertOverwriteManager.finishTaskGroup(groupId);
} else {
// it's overwrite table(as all partitions) or specific
partition(s)
List<String> tempPartitionNames =
InsertOverwriteUtil.generateTempPartitionNames(partitionNames);
+
cancelTheOverwriteAt(DEBUG_POINT_CANCEL_BEFORE_THE_INSERT_OF_AN_OVERWRITE,
targetTable);
if (isCancelled.get()) {
- LOG.info("insert overwrite is cancelled before
registerTask, queryId: {}",
- ctx.getQueryIdentifier());
- return;
+ // Nothing durable happened: no task is registered, no
temp partition exists, no row was
+ // written and nothing was committed. The statement is a
plain failure, like the one the
+ // inner insert reports when it is cancelled, rather than
the success of an overwrite that
+ // did not run.
+ throw cancelledBeforeTheRowsWereCommitted("before
registerTask", ctx);
}
taskId = insertOverwriteManager.registerTask(targetTable,
tempPartitionNames);
if (isCancelled.get()) {
- LOG.info("insert overwrite is cancelled before
addTempPartitions, queryId: {}",
- ctx.getQueryIdentifier());
- // not need deal temp partition
- insertOverwriteManager.taskSuccess(taskId);
- return;
+ // The catch below takes the registration back; no temp
partition exists yet, so there is
+ // nothing else to drop.
+ throw cancelledBeforeTheRowsWereCommitted("before
addTempPartitions", ctx);
}
InsertOverwriteUtil.addTempPartitions(targetTable,
partitionNames, tempPartitionNames);
if (isCancelled.get()) {
- LOG.info("insert overwrite is cancelled before insertInto,
queryId: {}", ctx.getQueryIdentifier());
- insertOverwriteManager.taskFail(taskId);
- return;
+ // The catch below drops the temp partitions this
cancelled statement created.
+ throw cancelledBeforeTheRowsWereCommitted("before
insertInto", ctx);
}
// todo: need to refresh remote target table after add temp
partitions
- insertIntoPartitions(ctx, executor, tempPartitionNames,
wholeTable);
+ InsertCommandContext insertCtx = insertIntoPartitions(ctx,
executor, tempPartitionNames, wholeTable);
+
cancelTheOverwriteAt(DEBUG_POINT_CANCEL_BETWEEN_THE_HALVES_OF_AN_OVERWRITE,
targetTable);
if (isCancelled.get()) {
- LOG.info("insert overwrite is cancelled before
replacePartition, queryId: {}",
- ctx.getQueryIdentifier());
- insertOverwriteManager.taskFail(taskId);
- return;
+ if (!insertCtx.hasCommitted()) {
+ // The insert committed nothing: its plan folded to an
empty relation, so it took the
+ // path that begins no transaction, and this window
holds no durable work at all.
+ // Completing the swap would publish an empty table
for a statement the client
+ // cancelled; the catch drops the empty temp
partitions instead and the statement
+ // fails, which is the same boundary the cancellations
above sit on.
+ throw cancelledBeforeTheRowsWereCommitted("after an
insert that committed nothing", ctx);
+ }
+ // Too late to cancel: insertIntoPartitions returns only
once its transaction has committed
+ // the rows into the temp partitions -- visible, or still
waiting for a publication that
+ // timed out -- and everything the read consumed, the base
table stream offsets among it,
+ // was committed with that same transaction. Dropping the
temp partitions here is exactly
+ // what would lose those rows against an advanced offset,
while the swap below is what
+ // publishes them. The overwrite completes, and it is the
outcome the statement reports.
+ LOG.info("insert overwrite is cancelled after its rows
were committed, completing it,"
+ + " queryId: {}", ctx.getQueryIdentifier());
}
failBetweenTheTwoHalvesOfAnOverwrite(targetTable);
- InsertOverwriteUtil.replacePartition(targetTable,
partitionNames, tempPartitionNames,
- isForceDropPartition());
+ // The publication below is a lambda, so the partitions it
replaces need a name that is final:
+ // partitionNames is assigned on more than one path above.
+ final List<String> replacedPartitionNames = partitionNames;
+ publishTheOverwrite(targetTable, insertCtx, ctx,
+ () ->
InsertOverwriteUtil.replacePartition(targetTable, replacedPartitionNames,
+ tempPartitionNames, isForceDropPartition()));
if (isCancelled.get()) {
LOG.info("insert overwrite is cancelled before
taskSuccess, do nothing, queryId: {}",
ctx.getQueryIdentifier());
@@ -300,9 +378,10 @@ public class InsertOverwriteTableCommand extends Command
}
} catch (Exception e) {
LOG.warn("insert into overwrite failed with task(or group) id {}",
taskId, e);
- if (isAutoDetectOverwrite(getLogicalQuery())) {
+ // A cancel that landed before registerTask leaves nothing
registered to fail, and no id was taken.
+ if (isAutoDetectOverwrite(getLogicalQuery()) && taskId != 0) {
insertOverwriteManager.taskGroupFail(taskId);
- } else {
+ } else if (taskId != 0) {
insertOverwriteManager.taskFail(taskId);
}
throw e;
@@ -382,12 +461,91 @@ public class InsertOverwriteTableCommand extends Command
throw new UserException("debug point: " +
DEBUG_POINT_FAIL_BETWEEN_THE_HALVES_OF_AN_OVERWRITE);
}
+ /**
+ * Cancels this overwrite when the debug point names the table it targets;
see the constants above.
+ * Nothing here decides what a cancelled overwrite means -- the call sites
do, and they differ: the ones
+ * before the rows are durable take the statement back, the one after them
does not.
+ *
+ * <p>One lookup, because a point is consumed by the lookup that reads it:
reading it twice with
+ * {@code execute=1} armed would have the first read spend the allowance
and the point be gone before the
+ * second, which would silently leave the overwrite uncancelled.
+ */
+ private void cancelTheOverwriteAt(String debugPointName, TableIf
targetTable) {
+ if
(!targetTable.getName().equals(DebugPointUtil.getDebugParamOrDefault(
+ debugPointName, "table_name", ""))) {
+ return;
+ }
+ LOG.info("debug point {} cancels the overwrite of {}", debugPointName,
targetTable.getName());
+ cancel();
+ }
+
+ /**
+ * Publishes this overwrite by running its swap, with a last look at the
cancellation flag taken under the
+ * lock the swap contends for.
+ *
+ * <p>{@link #run} reads the flag before the swap is issued, and the swap
then waits for the table's write
+ * lock, so a cancellation that arrives during that wait is the one place
a check before the swap cannot
+ * see. Reading it again here costs nothing and is where the wait happens:
for a cancellation with nothing
+ * committed there is nothing durable to publish, so refusing to swap
costs the statement and leaves the
+ * rows the client asked to keep -- while a swap that went ahead would
replace them with an empty result.
+ *
+ * <p>Only a local table is wrapped: a remote table swaps on the frontend
that owns it, where this lock
+ * says nothing.
+ */
+ private void publishTheOverwrite(TableIf targetTable, InsertCommandContext
insertCtx, ConnectContext ctx,
+ OverwritePublication publication) throws UserException {
+ if (!(targetTable instanceof OlapTable) || targetTable instanceof
RemoteOlapTable) {
+ publication.publish();
+ return;
+ }
+ OlapTable olapTable = (OlapTable) targetTable;
+ if (!olapTable.writeLockIfExist()) {
+ // The target was dropped while this overwrite ran, so there is
nothing to publish into and no swap
+ // to issue. Failing is also what the utility's own early return
did for a dropped table -- its
+ // finally unlocks a lock that return never took, which raises --
and it is what a client whose
+ // swap never happened is owed: acknowledging the overwrite would
claim rows the table cannot hold.
+ // The catch drops the temp partitions of the dropped table and
takes the task back.
+ throw new UserException("insert overwrite could not publish its
temporary partitions: table "
+ + olapTable.getName() + " was dropped, queryId: " +
ctx.getQueryIdentifier());
+ }
+ try {
+
cancelTheOverwriteAt(DEBUG_POINT_CANCEL_WHILE_THE_SWAP_WAITS_FOR_THE_TABLE_LOCK,
targetTable);
+ if (isCancelled.get() && !insertCtx.hasCommitted()) {
+ throw cancelledBeforeTheRowsWereCommitted("while the swap
waited for the table lock", ctx);
+ }
+ publication.publish();
+ } finally {
+ olapTable.writeUnlock();
+ }
+ }
+
+ /**
+ * The failure a cancellation that found nothing durable is reported as.
No row and no stream offset was
+ * committed, so a re-run reads the same rows -- which is why this is a
failure rather than the success of
+ * an overwrite that never ran. What a cancellation means on the other
side of that boundary, where the
+ * rows are durable, is decided where the swap runs.
+ */
+ private static UserException cancelledBeforeTheRowsWereCommitted(String
stage, ConnectContext ctx) {
+ return new UserException("insert overwrite is cancelled " + stage + ",
queryId: "
+ + ctx.getQueryIdentifier());
+ }
+
private void runInsertCommand(LogicalPlan logicalQuery,
InsertCommandContext insertCtx,
ConnectContext ctx, StmtExecutor executor) throws Exception {
InsertIntoTableCommand insertCommand = new
InsertIntoTableCommand(logicalQuery, labelName,
Optional.of(insertCtx), Optional.empty(), false, branchName);
insertCommand.run(ctx, executor);
if (ctx.getState().getStateType() == MysqlStateType.ERR) {
+ if (insertCtx.hasCommitted()) {
+ // The rows are durable and only their publication timed out,
which the session's
+ // visibility-timeout mode turns into this error
(`insert_visible_timeout_return_mode=error`).
+ // Dropping the temp partitions for it would lose exactly what
the error says was committed,
+ // so the overwrite keeps going: the swap below publishes the
rows, and the error the client
+ // gets stays what it is -- a statement about visibility, not
about whether the overwrite ran.
+ LOG.info("insert overwrite continues over an error state whose
rows are committed, queryId: {}",
+ ctx.getQueryIdentifier());
+ return;
+ }
String errMsg =
Strings.emptyToNull(ctx.getState().getErrorMessage());
LOG.warn("InsertInto state error:{}", errMsg);
throw new UserException(errMsg);
@@ -401,9 +559,11 @@ public class InsertOverwriteTableCommand extends Command
* @param executor executor
* @param tempPartitionNames tempPartitionNames
* @param wholeTable overwrite target is the whole table. not one
by one by partitions(...)
+ * @return the context the inner insert ran under, which says whether it
committed anything; see
+ * {@link InsertCommandContext#hasCommittedNothing()}
*/
- private void insertIntoPartitions(ConnectContext ctx, StmtExecutor
executor, List<String> tempPartitionNames,
- boolean wholeTable)
+ private InsertCommandContext insertIntoPartitions(ConnectContext ctx,
StmtExecutor executor,
+ List<String> tempPartitionNames, boolean wholeTable)
throws Exception {
// copy sink tot replace by tempPartitions
UnboundLogicalSink<?> copySink;
@@ -448,6 +608,7 @@ public class InsertOverwriteTableCommand extends Command
throw new UserException("Current catalog does not support insert
overwrite yet.");
}
runInsertCommand(copySink, insertCtx, ctx, executor);
+ return insertCtx;
}
/**
@@ -455,8 +616,11 @@ public class InsertOverwriteTableCommand extends Command
*
* @param ctx ctx
* @param executor executor
+ * @return the context the inner insert ran under, which says whether it
committed anything; see
+ * {@link InsertCommandContext#hasCommittedNothing()}
*/
- private void insertIntoAutoDetect(ConnectContext ctx, StmtExecutor
executor, long groupId) throws Exception {
+ private InsertCommandContext insertIntoAutoDetect(ConnectContext ctx,
StmtExecutor executor, long groupId)
+ throws Exception {
InsertCommandContext insertCtx;
LogicalPlan logicalQuery = getLogicalQuery();
if (logicalQuery instanceof UnboundTableSink) {
@@ -469,6 +633,7 @@ public class InsertOverwriteTableCommand extends Command
throw new UserException("Current catalog does not support insert
overwrite with auto-detect partition.");
}
runInsertCommand(logicalQuery, insertCtx, ctx, executor);
+ return insertCtx;
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java
index 29b80a91aad..70387f50619 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java
@@ -240,10 +240,14 @@ public class OlapInsertExecutor extends
AbstractInsertExecutor {
ctx.getSessionVariable().getInsertVisibleTimeoutMs(),
txnCommitAttachment,
streamUpdateInfos)) {
txnStatus = TransactionStatus.VISIBLE;
+ markCommitted();
} else {
// Keep the committed status so load accounting and insert result
bookkeeping stay aligned.
txnStatus = TransactionStatus.COMMITTED;
publishTimedOutAfterCommit = true;
+ // Committed, visible later: the rows are durable even though the
session's visibility-timeout
+ // mode may report the timeout as an error. See
InsertCommandContext#setCommitted.
+ markCommitted();
}
if (Config.isCloudMode()) {
String clusterName = ctx.getCloudCluster();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RemoteOlapInsertExecutor.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RemoteOlapInsertExecutor.java
index c95c3b0d71d..511a0a5bd4e 100755
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RemoteOlapInsertExecutor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/RemoteOlapInsertExecutor.java
@@ -208,6 +208,7 @@ public class RemoteOlapInsertExecutor extends
OlapInsertExecutor {
} else {
txnStatus = TransactionStatus.COMMITTED;
}
+ markCommitted();
LOG.info("commit remote txn success, catalog={}, dbId={},
txnId={}, status={}",
remoteCatalog.getName(), database.getId(),
txnId, txnStatus);
} else {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteDroppedTargetTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteDroppedTargetTest.java
new file mode 100644
index 00000000000..ac1dd5e54c2
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteDroppedTargetTest.java
@@ -0,0 +1,73 @@
+// 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.doris.nereids.trees.plans.commands.insert;
+
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.qe.ConnectContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * What the swap of an overwrite does when its target has been dropped
underneath it.
+ *
+ * <p>The swap takes the target table's write lock, and a target dropped by a
concurrent DROP no longer
+ * offers one. Skipping the swap there and returning lets {@code run}
acknowledge the overwrite with a
+ * success for a table that holds nothing it wrote, which is the one outcome a
client must not be told;
+ * {@link InsertOverwriteTableCommand} raises instead, so the caller's catch
drops the temp partitions and
+ * the statement fails. This pins that branch: the table a lock could not be
taken on has to be named in
+ * the failure.
+ *
+ * <p>Driven directly rather than through a statement because the window needs
a DROP concurrent with the
+ * swap, which a regression test cannot place without an injection point of
its own; the condition the
+ * branch reads -- {@code writeLockIfExist()} answering false -- is what the
mock supplies, and the
+ * publication it is handed records whether it was reached at all.
+ */
+public class InsertOverwriteDroppedTargetTest {
+
+ @Test
+ public void targetDroppedBeforeTheSwapFailsTheOverwrite() {
+ InsertOverwriteTableCommand command = new InsertOverwriteTableCommand(
+ Mockito.mock(LogicalPlan.class), Optional.empty(),
Optional.empty(), Optional.empty());
+ OlapTable droppedTarget = Mockito.mock(OlapTable.class);
+ Mockito.when(droppedTarget.getName()).thenReturn("iot_dropped_target");
+ Mockito.when(droppedTarget.writeLockIfExist()).thenReturn(false);
+ ConnectContext ctx = Mockito.mock(ConnectContext.class);
+ Mockito.when(ctx.getQueryIdentifier()).thenReturn("stmt[1, query-id]");
+ AtomicBoolean published = new AtomicBoolean(false);
+
+ UserException thrown = Assertions.assertThrows(UserException.class,
+ () -> Deencapsulation.invoke(command, "publishTheOverwrite",
droppedTarget,
+ new OlapInsertCommandContext(false, true), ctx,
+ (InsertOverwriteTableCommand.OverwritePublication) ()
-> published.set(true)));
+ Assertions.assertTrue(thrown.getMessage() != null
+ && thrown.getMessage().contains("iot_dropped_target")
+ && thrown.getMessage().contains("was dropped"),
+ "the failure has to name the table whose swap could not be
issued, but was: "
+ + thrown.getMessage());
+ Assertions.assertFalse(published.get(),
+ "nothing may be published into a table whose write lock could
not be taken");
+ }
+}
diff --git
a/regression-test/data/insert_overwrite_p0/test_insert_overwrite_cancel.out
b/regression-test/data/insert_overwrite_p0/test_insert_overwrite_cancel.out
new file mode 100644
index 00000000000..90d04658c0a
--- /dev/null
+++ b/regression-test/data/insert_overwrite_p0/test_insert_overwrite_cancel.out
@@ -0,0 +1,24 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !dst_after_the_cancelled_statement --
+1 2026-01-10 100
+2 2026-02-10 200
+
+-- !dst_after_the_cancelled_window --
+3 2026-01-20 300
+4 2026-02-20 400
+
+-- !dst_after_the_cancelled_empty_overwrite --
+1 2026-01-10 100
+
+-- !dst_after_the_cancelled_locked_swap --
+1 2026-01-10 100
+
+-- !dst_after_the_cancelled_auto_detect_swap --
+3 2026-01-20 300
+4 2026-02-20 400
+
+-- !dst_after_the_publish_timeout --
+3 2026-01-20 300
+4 2026-02-20 400
+5 2026-01-25 500
+
diff --git
a/regression-test/suites/insert_overwrite_p0/test_insert_overwrite_cancel.groovy
b/regression-test/suites/insert_overwrite_p0/test_insert_overwrite_cancel.groovy
new file mode 100644
index 00000000000..f83314073dd
--- /dev/null
+++
b/regression-test/suites/insert_overwrite_p0/test_insert_overwrite_cancel.groovy
@@ -0,0 +1,246 @@
+// 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.
+
+/**
+ * What an overwrite owes the rows it has already committed, whether it is
cancelled or comes back with an
+ * error, and what it owes the caller when it has committed nothing.
+ *
+ * <p>An overwrite commits its rows into temporary partitions and publishes
them with a swap afterwards, so a
+ * cancellation that lands between the two halves cannot take the rows back:
they are durable, and everything
+ * the write read -- the base table stream offsets among it -- was committed
with them. Dropping the temporary
+ * partitions there, and answering the client with a success for an overwrite
that never happened, is what
+ * loses those rows against an advanced offset; the swap is what has to run
instead. Where the insert committed
+ * nothing at all -- its plan folded to an empty relation, so it took the
insert's no-transaction path -- the
+ * cancellation still has everything to take back, and completing the swap
there would publish an empty table
+ * for a statement the client cancelled.
+ *
+ * <p>The same boundary decides what an error response may do to those rows: a
load whose publication times out
+ * after its commit is committed, and the session's visibility-timeout mode
reports that timeout as an error.
+ * The response is an error, but the rows exist, so the swap has to run for
that case too.
+ *
+ * <p>Where a cancellation lands inside the window, but the swap is already
waiting for the table's write lock,
+ * the flag is read once more under that lock: nothing is durable, so the
overwrite can still be refused there
+ * instead of publishing empty partitions.
+ *
+ * <p>The cancellation cases are injected by their own debug point in the
overwrite, scoped by table name, so
+ * they land on real statements rather than on a race; the window is asserted
for the rows it publishes, for
+ * the empty plan whose statement fails only if the injection reached the
window, and for the lock the swap
+ * waits on. The publication timeout is driven by blocking the publish daemon,
as
+ * test_insert_visible_timeout_return_mode does.
+ */
+suite("test_insert_overwrite_cancel", "nonConcurrent") {
+ def beforePoint =
"InsertOverwriteTableCommand.cancelBeforeTheInsertOfAnOverwrite"
+ def betweenPoint =
"InsertOverwriteTableCommand.cancelBetweenTheTwoHalvesOfAnOverwrite"
+ def waitsPoint =
"InsertOverwriteTableCommand.cancelWhileTheSwapWaitsForTheTableLock"
+ def stopPublishPoint = "PublishVersionDaemon.stop_publish"
+
+ GetDebugPoint().disableDebugPointForAllFEs(beforePoint)
+ GetDebugPoint().disableDebugPointForAllFEs(betweenPoint)
+ GetDebugPoint().disableDebugPointForAllFEs(waitsPoint)
+ GetDebugPoint().disableDebugPointForAllFEs(stopPublishPoint)
+ sql """DROP TABLE IF EXISTS test_iot_cancel_src"""
+ sql """DROP TABLE IF EXISTS test_iot_cancel_dst"""
+ sql """DROP TABLE IF EXISTS test_iot_cancel_flat_src"""
+ sql """DROP TABLE IF EXISTS test_iot_cancel_flat_dst"""
+
+ sql """
+ CREATE TABLE test_iot_cancel_src (
+ id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ amount INT
+ ) ENGINE = OLAP
+ UNIQUE KEY(id, dt)
+ PARTITION BY RANGE(dt) (
+ PARTITION p1 VALUES [('2026-01-01'), ('2026-02-01')),
+ PARTITION p2 VALUES [('2026-02-01'), ('2026-03-01'))
+ )
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true"
+ )
+ """
+ sql """
+ CREATE TABLE test_iot_cancel_dst (
+ id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ amount INT
+ ) ENGINE = OLAP
+ UNIQUE KEY(id, dt)
+ PARTITION BY RANGE(dt) (
+ PARTITION p1 VALUES [('2026-01-01'), ('2026-02-01')),
+ PARTITION p2 VALUES [('2026-02-01'), ('2026-03-01'))
+ )
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true"
+ )
+ """
+ sql """INSERT INTO test_iot_cancel_dst VALUES (1, '2026-01-10', 100), (2,
'2026-02-10', 200)"""
+ sql """INSERT INTO test_iot_cancel_src VALUES (3, '2026-01-20', 300), (4,
'2026-02-20', 400)"""
+
+ // Unpartitioned, for the empty-plan case below: a partitioned target
gives the plan a coordinator exchange,
+ // so the sink's child is not the empty relation and the insert commits an
empty transaction instead of
+ // taking the path that commits nothing.
+ sql """
+ CREATE TABLE test_iot_cancel_flat_src (
+ id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ amount INT
+ ) ENGINE = OLAP
+ UNIQUE KEY(id, dt)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true"
+ )
+ """
+ sql """
+ CREATE TABLE test_iot_cancel_flat_dst (
+ id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ amount INT
+ ) ENGINE = OLAP
+ UNIQUE KEY(id, dt)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true"
+ )
+ """
+ sql """INSERT INTO test_iot_cancel_flat_dst VALUES (1, '2026-01-10',
100)"""
+ sql """INSERT INTO test_iot_cancel_flat_src VALUES (3, '2026-01-20',
300)"""
+
+ // A cancellation that lands before anything was committed has nothing to
take back, so the statement has
+ // to report it, and the table has to be where it was.
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(beforePoint,
+ [table_name: "test_iot_cancel_dst"])
+ test {
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_dst SELECT * FROM
test_iot_cancel_src"""
+ exception "insert overwrite is cancelled before registerTask"
+ }
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(beforePoint)
+ }
+ order_qt_dst_after_the_cancelled_statement """SELECT id, dt, amount FROM
test_iot_cancel_dst"""
+
+ // A cancellation that lands after the rows are committed cannot take them
back: they are in the temporary
+ // partitions, and the swap is what publishes them. The statement reports
the success it now is, and the
+ // rows it read are the ones the table holds.
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(betweenPoint,
+ [table_name: "test_iot_cancel_dst"])
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_dst SELECT * FROM
test_iot_cancel_src"""
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(betweenPoint)
+ }
+ order_qt_dst_after_the_cancelled_window """SELECT id, dt, amount FROM
test_iot_cancel_dst"""
+
+ // The same cancellation, but the insert committed nothing: an empty plan
takes the insert's
+ // no-transaction path, so the temporary partitions hold no rows and there
is no offset to keep. The
+ // statement has to fail and the table has to keep the rows it had -- a
swap here would publish the empty
+ // result for a statement the client cancelled. This case also pins that
the cancellation above is really
+ // delivered: its assertion holds only if the injection reached the window.
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(betweenPoint,
+ [table_name: "test_iot_cancel_flat_dst"])
+ test {
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_flat_dst
+ SELECT id, dt, amount FROM test_iot_cancel_flat_src WHERE 1
= 0"""
+ exception "insert overwrite is cancelled after an insert that
committed nothing"
+ }
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(betweenPoint)
+ }
+ order_qt_dst_after_the_cancelled_empty_overwrite """SELECT id, dt, amount
FROM test_iot_cancel_flat_dst"""
+
+ // The last chance to honour a cancellation that found nothing committed:
this one is delivered while the
+ // swap holds the table's write lock, after the check that reads the flag
before the swap was issued. The
+ // table has to keep its rows, because a swap there would publish the
empty partitions.
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(waitsPoint,
+ [table_name: "test_iot_cancel_flat_dst"])
+ test {
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_flat_dst
+ SELECT id, dt, amount FROM test_iot_cancel_flat_src WHERE 1
= 0"""
+ exception "insert overwrite is cancelled while the swap waited for
the table lock"
+ }
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(waitsPoint)
+ }
+ order_qt_dst_after_the_cancelled_locked_swap """SELECT id, dt, amount FROM
test_iot_cancel_flat_dst"""
+
+ // The auto-detect route takes the same decision at the same point, and it
needs it: with
+ // enable_strict_consistency_dml off, an empty-plan `PARTITION(*)`
overwrite has no exchange in its plan
+ // either, so it takes the insert's no-transaction path as well and its
swap has nothing to publish. The
+ // cancellation has to be honoured there too, and the table has to keep
its rows.
+ try {
+ sql """SET enable_strict_consistency_dml = false"""
+ GetDebugPoint().enableDebugPointForAllFEs(waitsPoint,
+ [table_name: "test_iot_cancel_dst"])
+ test {
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_dst PARTITION(*)
+ SELECT id, dt, amount FROM test_iot_cancel_src WHERE 1 =
0"""
+ exception "insert overwrite is cancelled while the swap waited for
the table lock"
+ }
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(waitsPoint)
+ sql """SET enable_strict_consistency_dml = true"""
+ }
+ order_qt_dst_after_the_cancelled_auto_detect_swap """SELECT id, dt, amount
FROM test_iot_cancel_dst"""
+
+ // An error response over committed rows:
`insert_visible_timeout_return_mode=error` turns a publication
+ // timeout that follows the commit into an error, but the rows the
overwrite wrote are durable, so the
+ // overwrite has to be published rather than dropped with the temporary
partitions. The row the overwrite
+ // reads is new, so the table only holds it if the swap ran.
+ //
+ // Cloud mode is skipped: its commit has no FE publication wait
(`CloudGlobalTransactionMgr` commits
+ // straight into the meta service), so blocking the publish daemon leaves
the insert reporting success and
+ // there is no timeout to reproduce. The cancellation cases above do not
depend on that and stay active.
+ if (!isCloudMode()) {
+ sql """INSERT INTO test_iot_cancel_src VALUES (5, '2026-01-25', 500)"""
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(stopPublishPoint,
[timeout: "10"])
+ sql """SET insert_visible_timeout_ms = 1000"""
+ sql """SET insert_visible_timeout_return_mode = 'error'"""
+ test {
+ sql """INSERT OVERWRITE TABLE test_iot_cancel_dst SELECT *
FROM test_iot_cancel_src"""
+ exception "transaction commit successfully, BUT data did not
become visible"
+ }
+ } finally {
+ GetDebugPoint().disableDebugPointForAllFEs(stopPublishPoint)
+ // Back to the defaults (DEFAULT_INSERT_VISIBLE_TIMEOUT_MS =
60000, 'committed').
+ sql """SET insert_visible_timeout_ms = 60000"""
+ sql """SET insert_visible_timeout_return_mode = 'committed'"""
+ }
+
+ // Publish resumes on its own, and the rows that were committed under
the error come with it.
+ def published = false
+ for (int i = 0; i < 15; i++) {
+ def rows = sql """SELECT count(*) FROM test_iot_cancel_dst"""
+ if ((rows[0][0] as long) == 3L) {
+ published = true
+ break
+ }
+ sleep(1000)
+ }
+ assertTrue(published, "The committed overwrite should become visible
after publish resumes")
+ order_qt_dst_after_the_publish_timeout """SELECT id, dt, amount FROM
test_iot_cancel_dst"""
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]