Skip to content

Commit 6a6849a

Browse files
gengjun-gitclaude
andcommitted
[Enhancement] Reset alter jobs to their last durable state on in-place leader demotion
AlterJobV2 state machines deliberately keep some progress in memory only: the WAITING_TXN -> RUNNING transitions are not journaled ("DO NOT write edit log here, tasks will be send again if FE restart or master changed") and dispatch state lives in transient batch tasks / futures. That convention relied on the process restart at leader switch to reload jobs from the image and implicitly discard the divergence. With in-place demotion the same objects survive, so on same-node re-election a job would dispatch by its unlogged in-memory state and wait on stale leader-session dispatch state instead of re-sending work: runRunningJob polls a batch whose queue entries were dropped by the demotion drain (wedge until timeout-cancel), re-entering runWaitingTxnJob APPENDS into a stale batch (duplicates never get finish callbacks), the fast-path null-guard skips the re-dispatch entirely, and OnlineOptimizeJobV2 could consume a stale INSERT future for a task whose INSERT never ran and durably swap in an empty temp partition. Introduce AlterJobV2.resetOnLeaderHandoff() ("demote = logical restart"): under the job monitor, non-final jobs cancel and drop publishVersionFuture and delegate to a per-class resetTransientStateForHandoff() that maps in-memory-only states back to their last durable predecessor and recreates/clears leader-session transients: - SchemaChangeJobV2 / RollupJobV2: RUNNING -> WAITING_TXN; fresh batch; latch/flags reset; discard an unlogged watershed on a durable-PENDING job. - OptimizeJobV2: RUNNING -> WAITING_TXN; restore rewriteTasks/sourcePartitionNames/ tmpPartitionNames/allPartitionOptimized/distributionInfo to the durable snapshot; undo a partial tmpPartitionIds append at PENDING. optimizeClause kept (a real reload nulls it and self-cancels; keeping it is strictly better). - OnlineOptimizeJobV2: additionally cancel(true) + drop the in-flight INSERT future (stale-result cross-task attribution is data-loss grade) and clear the never-journaled double-write partition routing. - LakeTableSchemaChangeJob / LakeRollupJob: RUNNING -> WAITING_TXN; dequeue + fresh batch. LakeRollupJob keeps whereClause (no gsonPostProcess restore exists; a real reload silently loses the sync-MV filter). - LakeTableAlterMetaJobBase: RUNNING -> PENDING (WAITING_TXN is skipped on its live path; replay throws on RUNNING); drop the per-dispatch batch. - LakeTableIndexFastPathJobBase: WAITING_TXN and RUNNING are both in-memory only -> PENDING; dequeue tasks and null the batch so the null-guard re-dispatches. AlterHandler.onStopped() sweeps alterJobsV2 with the reset (worker joined = run-path quiescent) and start() repeats the idempotent sweep before the first scheduling cycle. MaterializedViewHandler.onStopped() also clears tableRunningJobMap, fixing a pre-existing cross-leadership slot leak (a job finished and expiry-removed by an interim leader is removed from alterJobsV2 by replay but never from the gate, permanently suspending new rollups on that table). Add LeaderHandoffResetTest covering the state mapping, transient cleanup, PENDING partial-rollback, final-state immutability, publish-future handling, and the handler sweep. Some transient fields become package-private for these tests (no reflection). Signed-off-by: gengjun-git <gengjun@starrocks.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 701d98e commit 6a6849a

12 files changed

Lines changed: 481 additions & 7 deletions

fe/fe-core/src/main/java/com/starrocks/alter/AlterHandler.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ public synchronized void start() {
218218
if (executor == null || executor.isShutdown()) {
219219
executor = newExecutor();
220220
}
221+
// Idempotent insurance for the onStopped() sweep: covers a stop path that never ran
222+
// onStopped (e.g. a plain setStop() without join) and guarantees every job is reset
223+
// before the first scheduling cycle can dispatch it by its in-memory state.
224+
resetJobsOnLeaderHandoff();
221225
super.start();
222226
}
223227

@@ -233,6 +237,21 @@ protected void onStopped() {
233237
if (executor != null && !executor.isShutdown()) {
234238
executor.shutdownNow();
235239
}
240+
// The jobs themselves survive in memory across an in-place demote / re-elect cycle,
241+
// unlike a restart which reloads them from the image/journal. Reset each non-final
242+
// job to its last durable state (drop unlogged in-memory transitions and leader-
243+
// session transients) so a re-elected leader resumes exactly like a restarted FE.
244+
resetJobsOnLeaderHandoff();
245+
}
246+
247+
private void resetJobsOnLeaderHandoff() {
248+
for (AlterJobV2 job : alterJobsV2.values()) {
249+
try {
250+
job.resetOnLeaderHandoff();
251+
} catch (Throwable t) {
252+
LOG.warn("reset alter job {} on leader handoff failed", job.getJobId(), t);
253+
}
254+
}
236255
}
237256

238257
/*

fe/fe-core/src/main/java/com/starrocks/alter/AlterJobV2.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,39 @@ public void setFinishedTimeMs(long finishedTimeMs) {
233233
this.finishedTimeMs = finishedTimeMs;
234234
}
235235

236+
/**
237+
* Reset this job's in-memory state to its last durable (journaled) equivalent when the
238+
* leader demotes in place. Historically "FE restart or master changed" reloaded jobs from
239+
* the image/journal, which implicitly discarded in-memory-only progress (the deliberately
240+
* unlogged WAITING_TXN -&gt; RUNNING transitions) and leader-session transients (batch
241+
* tasks, latches, futures). In-place demotion keeps the very same objects alive, so this
242+
* hook performs that normalization explicitly: on re-election the job re-enters its last
243+
* durable state and re-sends its work from scratch, exactly like a restarted FE.
244+
*
245+
* Synchronized on the job: run() and cancel() use the same monitor, so a straggling user
246+
* cancel or a finish-callback thread surviving the executor's shutdownNow() cannot
247+
* interleave with the reset. Final states are left untouched. Must NOT write to the
248+
* journal - it is already sealed when the demotion drain invokes this.
249+
*/
250+
public synchronized void resetOnLeaderHandoff() {
251+
if (jobState.isFinalState()) {
252+
return;
253+
}
254+
if (publishVersionFuture != null) {
255+
publishVersionFuture.cancel(false);
256+
publishVersionFuture = null;
257+
}
258+
resetTransientStateForHandoff();
259+
}
260+
261+
/**
262+
* Subclass hook for {@link #resetOnLeaderHandoff()}: map in-memory-only job states back
263+
* to their last durable predecessor and recreate/clear leader-session transients (batch
264+
* tasks, latches, flags, futures). Runs under the job monitor with the journal sealed.
265+
*/
266+
protected void resetTransientStateForHandoff() {
267+
}
268+
236269
public void setComputeResource(ComputeResource computeResource) {
237270
this.computeResource = computeResource;
238271
this.warehouseId = computeResource.getWarehouseId();

fe/fe-core/src/main/java/com/starrocks/alter/LakeRollupJob.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ public class LakeRollupJob extends LakeTableSchemaChangeJobBase {
126126
// save all create rollup tasks
127127
protected AgentBatchTask rollupBatchTask = new AgentBatchTask();
128128

129+
@Override
130+
protected void resetTransientStateForHandoff() {
131+
// WAITING_TXN -> RUNNING is deliberately not journaled; map it back so the re-elected
132+
// leader re-enters runWaitingTxnJob and re-sends the tasks.
133+
if (jobState == JobState.RUNNING) {
134+
jobState = JobState.WAITING_TXN;
135+
}
136+
// Same normalization replay() performs: drop queue entries and start from an empty
137+
// batch - runWaitingTxnJob appends directly to the field (double-add hazard).
138+
AgentTaskQueue.removeBatchTask(rollupBatchTask, TTaskType.ALTER);
139+
rollupBatchTask = new AgentBatchTask();
140+
// whereClause deliberately KEPT: this class has no gsonPostProcess restore, so a real
141+
// reload silently loses the sync-MV filter (pre-existing reload bug); keep the
142+
// strictly-better in-memory value.
143+
}
144+
129145
public LakeRollupJob(long jobId, long dbId, long tableId, String tableName, long timeoutMs,
130146
long baseIndexMetaId, long rollupIndexMetaId, String baseIndexName, String rollupIndexName,
131147
int rollupSchemaVersion, List<Column> rollupSchema, Expr whereClause, int baseSchemaHash,

fe/fe-core/src/main/java/com/starrocks/alter/LakeTableAlterMetaJobBase.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,27 @@ public abstract class LakeTableAlterMetaJobBase extends AlterJobV2 {
7575
private Table<Long, Long, MaterializedIndex> physicalPartitionIndexMap = HashBasedTable.create();
7676
@SerializedName(value = "commitVersionMap")
7777
private Map<Long, Long> commitVersionMap = new HashMap<>();
78-
private AgentBatchTask batchTask = null;
78+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
79+
AgentBatchTask batchTask = null;
7980
private boolean isFileBundling = false;
8081

8182
public LakeTableAlterMetaJobBase(JobType jobType) {
8283
super(jobType);
8384
}
8485

86+
@Override
87+
protected void resetTransientStateForHandoff() {
88+
// WAITING_TXN is skipped on the live path; RUNNING is the only in-memory-only state
89+
// and its durable predecessor is PENDING - the same-state re-log in runPendingJob
90+
// already persisted the watershed, and the -1 guard prevents re-allocation on re-run.
91+
// replay() throws on RUNNING, so it must never leak into any persisted copy.
92+
if (jobState == JobState.RUNNING) {
93+
jobState = JobState.PENDING;
94+
}
95+
// Recreated fresh per dispatch; a stale value only skews SHOW ALTER progress.
96+
batchTask = null;
97+
}
98+
8599
public LakeTableAlterMetaJobBase(long jobId, JobType jobType, long dbId, long tableId,
86100
String tableName, long timeoutMs) {
87101
super(jobId, jobType, dbId, tableId, tableName, timeoutMs);

fe/fe-core/src/main/java/com/starrocks/alter/LakeTableIndexFastPathJobBase.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ protected LakeTableIndexFastPathJobBase(JobType type) {
122122
super(type);
123123
}
124124

125+
@Override
126+
protected void resetTransientStateForHandoff() {
127+
// BOTH WAITING_TXN and RUNNING are in-memory only for this job family; PENDING is the
128+
// only durable predecessor (its same-state re-log carries the watershed and the
129+
// tablet snapshot, and the watershedTxnId != -1 guard makes the re-run idempotent).
130+
if (jobState == JobState.WAITING_TXN || jobState == JobState.RUNNING) {
131+
jobState = JobState.PENDING;
132+
}
133+
// runRunningJob dispatches ONLY behind a null guard - a stale batch would silently
134+
// suppress the re-send and wedge the job until timeout. Drop queue entries (mirror
135+
// cancelImpl) and null the field so the re-run re-dispatches.
136+
if (batchTask != null) {
137+
for (AgentTask task : batchTask.getAllTasks()) {
138+
AgentTaskQueue.removeTask(task.getBackendId(), TTaskType.ALTER, task.getSignature());
139+
}
140+
batchTask = null;
141+
}
142+
}
143+
125144
protected LakeTableIndexFastPathJobBase(long jobId, JobType jobType, long dbId, long tableId, String tableName,
126145
long timeoutMs) {
127146
super(jobId, jobType, dbId, tableId, tableName, timeoutMs);

fe/fe-core/src/main/java/com/starrocks/alter/LakeTableSchemaChangeJob.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ public class LakeTableSchemaChangeJob extends LakeTableSchemaChangeJobBase {
158158
private List<Integer> sortKeyUniqueIds;
159159

160160
// save all schema change tasks
161-
private AgentBatchTask schemaChangeBatchTask = new AgentBatchTask();
161+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
162+
AgentBatchTask schemaChangeBatchTask = new AgentBatchTask();
162163

163164
// runtime variable for synchronization between cancel and runPendingJob
164165
private MarkedCountDownLatch<Long, Long> createReplicaLatch = null;
@@ -173,6 +174,24 @@ public LakeTableSchemaChangeJob() {
173174
super(JobType.SCHEMA_CHANGE);
174175
}
175176

177+
@Override
178+
protected void resetTransientStateForHandoff() {
179+
// WAITING_TXN -> RUNNING is deliberately not journaled; map it back so the re-elected
180+
// leader re-verifies the watershed and re-sends every AlterReplicaTask.
181+
if (jobState == JobState.RUNNING) {
182+
jobState = JobState.WAITING_TXN;
183+
}
184+
// Mirror cancelImpl's queue cleanup, then start from an empty batch: the WAITING_TXN
185+
// handler APPENDS to it (double-add hazard), and getInfo dereferences the field, so
186+
// fresh-empty rather than null. watershedTxnId/Gtid stay - they are durable with the
187+
// WAITING_TXN entry, and runPendingJob reassigns them unconditionally at PENDING.
188+
AgentTaskQueue.removeBatchTask(schemaChangeBatchTask, TTaskType.ALTER);
189+
schemaChangeBatchTask = new AgentBatchTask();
190+
createReplicaLatch = null;
191+
waitingCreatingReplica.set(false);
192+
isCancelling.set(false);
193+
}
194+
176195
public LakeTableSchemaChangeJob(long jobId, long dbId, long tableId, String tableName, long timeoutMs) {
177196
super(jobId, JobType.SCHEMA_CHANGE, dbId, tableId, tableName, timeoutMs);
178197
}

fe/fe-core/src/main/java/com/starrocks/alter/MaterializedViewHandler.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,21 @@ public MaterializedViewHandler() {
122122
// table id -> set of running job ids
123123
private final Map<Long, Set<Long>> tableRunningJobMap = new ConcurrentHashMap<>();
124124

125+
@Override
126+
protected void onStopped() {
127+
super.onStopped();
128+
// Leader-only concurrency gate, fully repopulated by runAlterJobV2 on the next
129+
// leadership. Clearing it also fixes a slot leak: a job that finished and was
130+
// expiry-removed by another leader while this node was a follower is removed from
131+
// alterJobsV2 by replay but never from this map, permanently consuming a per-table
132+
// slot and suspending new rollups on that table.
133+
// tableNotFinalStateJobMap is deliberately NOT cleared: replay maintains it, and it
134+
// drives the ROLLUP -> NORMAL table-state restore for jobs finished via replay.
135+
synchronized (tableRunningJobMap) {
136+
tableRunningJobMap.clear();
137+
}
138+
}
139+
125140
@Override
126141
public void addAlterJobV2(AlterJobV2 alterJob) {
127142
super.addAlterJobV2(alterJob);

fe/fe-core/src/main/java/com/starrocks/alter/OnlineOptimizeJobV2.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ public class OnlineOptimizeJobV2 extends AlterJobV2 implements GsonPostProcessab
8585

8686
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();
8787

88-
private Future<Constants.TaskRunState> future = null;
88+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
89+
Future<Constants.TaskRunState> future = null;
8990

9091
@SerializedName(value = "tmpPartitionIds")
9192
private List<Long> tmpPartitionIds = Lists.newArrayList();
@@ -96,7 +97,8 @@ public class OnlineOptimizeJobV2 extends AlterJobV2 implements GsonPostProcessab
9697
private Map<String, String> properties = Maps.newHashMap();
9798

9899
@SerializedName(value = "rewriteTasks")
99-
private List<OptimizeTask> rewriteTasks = Lists.newArrayList();
100+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
101+
List<OptimizeTask> rewriteTasks = Lists.newArrayList();
100102
private int progress = 0;
101103

102104
@SerializedName(value = "sourcePartitionNames")
@@ -144,6 +146,45 @@ protected OnlineOptimizeJobV2(OnlineOptimizeJobV2 job) {
144146
this.optimizeOperation = job.optimizeOperation;
145147
}
146148

149+
@Override
150+
protected void resetTransientStateForHandoff() {
151+
if (jobState == JobState.RUNNING) {
152+
jobState = JobState.WAITING_TXN;
153+
}
154+
// The single in-flight INSERT future is attributed to "the task currently being
155+
// processed" by position, not identity: a stale result from the previous leader
156+
// session would be consumed by a NEW task whose INSERT never ran, and its EMPTY temp
157+
// partition would be durably swapped in. Cancel hard and drop it.
158+
if (future != null) {
159+
future.cancel(true);
160+
future = null;
161+
}
162+
// runWaitingTxnJob APPENDS to rewriteTasks; watershedTxnId is reallocated per task
163+
// during RUNNING and its durable WAITING_TXN value is -1.
164+
rewriteTasks.clear();
165+
watershedTxnId = -1;
166+
progress = 0;
167+
if (jobState == JobState.PENDING) {
168+
tmpPartitionIds.clear();
169+
allPartitionOptimized = false;
170+
}
171+
// Drop the never-journaled double-write routing so the demoted node behaves like a
172+
// freshly loaded FE (best-effort: db/table may already be gone).
173+
try {
174+
Database db = GlobalStateMgr.getCurrentState().getLocalMetastore().getDb(dbId);
175+
if (db != null) {
176+
OlapTable tbl = (OlapTable) GlobalStateMgr.getCurrentState()
177+
.getLocalMetastore().getTable(db.getId(), tableId);
178+
if (tbl != null) {
179+
disableDoubleWritePartition(db, tbl);
180+
}
181+
}
182+
} catch (Throwable t) {
183+
LOG.warn("clear double-write partitions failed on leader handoff, job: {}", jobId, t);
184+
}
185+
// optimizeClause deliberately kept - see OptimizeJobV2.resetTransientStateForHandoff().
186+
}
187+
147188
public List<Long> getTmpPartitionIds() {
148189
return tmpPartitionIds;
149190
}

fe/fe-core/src/main/java/com/starrocks/alter/OptimizeJobV2.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ public class OptimizeJobV2 extends AlterJobV2 implements GsonPostProcessable {
8787
private Map<String, String> properties = Maps.newHashMap();
8888

8989
@SerializedName(value = "rewriteTasks")
90-
private List<OptimizeTask> rewriteTasks = Lists.newArrayList();
90+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
91+
List<OptimizeTask> rewriteTasks = Lists.newArrayList();
9192
private int progress = 0;
9293

9394
@SerializedName(value = "sourcePartitionNames")
@@ -135,6 +136,35 @@ protected OptimizeJobV2(OptimizeJobV2 job) {
135136
this.optimizeOperation = job.optimizeOperation;
136137
}
137138

139+
@Override
140+
protected void resetTransientStateForHandoff() {
141+
// WAITING_TXN -> RUNNING is deliberately not journaled; map it back so the re-elected
142+
// leader re-enters runWaitingTxnJob (rebuild + re-register the rewrite tasks).
143+
if (jobState == JobState.RUNNING) {
144+
jobState = JobState.WAITING_TXN;
145+
}
146+
// Restore serialized-but-diverged fields to their durable WAITING_TXN snapshot: the
147+
// journal copy was written before any rewrite task or finish bookkeeping existed, and
148+
// runWaitingTxnJob APPENDS to rewriteTasks (a stale list would double the tasks).
149+
rewriteTasks.clear();
150+
sourcePartitionNames.clear();
151+
tmpPartitionNames.clear();
152+
allPartitionOptimized = false;
153+
distributionInfo = null;
154+
progress = 0;
155+
if (jobState == JobState.PENDING) {
156+
// Undo a partially executed runPendingJob: a fenced attempt may have appended tmp
157+
// partition ids that the durable PENDING image does not have; re-running with the
158+
// stale list would pair 2N tmp partitions against N sources.
159+
tmpPartitionIds.clear();
160+
watershedTxnId = -1;
161+
}
162+
// optimizeClause is deliberately KEPT: a real reload nulls it and self-cancels the
163+
// job; keeping it is safe (every derived value is recomputed on re-run) and strictly
164+
// better. A mid-RUNNING handoff still ends in a clean cancel via the TaskManager task
165+
// name collision - identical to genuine-restart behavior.
166+
}
167+
138168
public List<Long> getTmpPartitionIds() {
139169
return tmpPartitionIds;
140170
}

fe/fe-core/src/main/java/com/starrocks/alter/RollupJobV2.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ public class RollupJobV2 extends AlterJobV2 implements GsonPostProcessable {
175175
private Expr whereClause;
176176

177177
// save all create rollup tasks
178-
private AgentBatchTask rollupBatchTask = new AgentBatchTask();
178+
// Package-private so same-package tests can verify the leader-handoff reset without reflection.
179+
AgentBatchTask rollupBatchTask = new AgentBatchTask();
179180

180181
// runtime variable for synchronization between cancel and runPendingJob
181182
private MarkedCountDownLatch<Long, Long> createReplicaLatch = null;
@@ -187,6 +188,26 @@ public RollupJobV2() {
187188
super(JobType.ROLLUP);
188189
}
189190

191+
@Override
192+
protected void resetTransientStateForHandoff() {
193+
// WAITING_TXN -> RUNNING is deliberately not journaled; map it back so the re-elected
194+
// leader re-enters runWaitingTxnJob and re-sends every AlterReplicaTask.
195+
if (jobState == JobState.RUNNING) {
196+
jobState = JobState.WAITING_TXN;
197+
}
198+
// runWaitingTxnJob APPENDS to the batch - see SchemaChangeJobV2 for the double-add hazard.
199+
rollupBatchTask = new AgentBatchTask();
200+
createReplicaLatch = null;
201+
waitingCreatingReplica.set(false);
202+
isCancelling.set(false);
203+
if (jobState == JobState.PENDING) {
204+
watershedTxnId = -1;
205+
}
206+
// whereClause is deliberately KEPT: gsonPostProcess only restores it for PENDING jobs,
207+
// so a true reload would silently lose the sync-MV filter (pre-existing reload bug);
208+
// the surviving in-memory value is strictly better and every derived value is recomputed.
209+
}
210+
190211
public RollupJobV2(long jobId, long dbId, long tableId, String tableName, long timeoutMs,
191212
long baseIndexMetaId, long rollupIndexMetaId, String baseIndexName, String rollupIndexName,
192213
int rollupSchemaVersion, List<Column> rollupSchema, Expr whereClause, int baseSchemaHash,

0 commit comments

Comments
 (0)