fightBoxing commented on issue #4172:
URL: https://github.com/apache/amoro/issues/4172#issuecomment-4318443862
### Reproduced & root-caused — details below
I managed to reproduce this in our env and captured the full stack trace.
Sharing the analysis so we can align on a fix direction.
---
#### 1. What we observed
Thrift server log (AMS side) when the process got stuck in `RUNNING`:
```
ERROR [thrift-server-optimize-manager-92301]
o.a.a.s.u.t.ThriftServiceMetricsHandler
Thrift service:DefaultOptimizingService.completeTask execute failed.
org.apache.ibatis.exceptions.PersistenceException:
### Error updating database.
Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Lock wait timeout exceeded; try restarting transaction
### The error may exist in file [.../mapper/TableMetaMapper.java]
### updateTableRuntime-Inline
### SQL: UPDATE table_runtime SET current_snapshot_id = ?,
current_change_snapshotId = ?,
last_optimized_snapshotId = ?, last_optimized_change_snapshotId = ?,
last_major_optimizing_time = ?, last_full_optimizing_time = ?,
last_minor_optimizing_time = ?, optimizing_status_code = ?,
optimizing_status_start_time = ?, optimizing_process_id = ?,
pending_input = ?, table_config = ? WHERE table_id = ?
```
Stack trace (trimmed to the essential frames):
```
at TableRuntime.persistUpdatingRuntime(TableRuntime.java:451)
at TableRuntime.lambda$beginCommitting$6(TableRuntime.java:243)
at StatedPersistentBase.invokeConsistency(StatedPersistentBase.java:47)
at TableRuntime.beginCommitting(TableRuntime.java:238)
at
OptimizingQueue$TableOptimizingProcess.acceptResult(OptimizingQueue.java:458)
at TaskRuntime.lambda$complete$0(TaskRuntime.java:80)
at StatedPersistentBase.invokeConsistency(StatedPersistentBase.java:47)
at TaskRuntime.complete(TaskRuntime.java:...)
at DefaultOptimizingService.completeTask(DefaultOptimizingService.java:...)
```
After this exception the state stayed:
- DB `optimizing_process.status = RUNNING`
- All `task_runtime.status = SUCCESS` (or `ACKED` for the last one that got
rolled back)
- Table `optimizing_status_code` still `*_OPTIMIZING`, **not** `COMMITTING`
- Only recovery path: nothing periodic — a full AMS restart is required so
the `reload()` path kicks in.
---
#### 2. Why the process hangs — root cause
The failing SQL is the `UPDATE table_runtime` issued by
`TableRuntime#beginCommitting()` to flip `optimizing_status_code` to
`COMMITTING`. It's triggered from inside the outer `TaskRuntime#complete`
transaction:
```
DefaultOptimizingService.completeTask
└─ TaskRuntime.complete (invokeConsistency #1,
wraps a transaction)
├─ statusMachine.accept(SUCCESS) // in-memory task ->
SUCCESS
├─ persistTaskRuntime() // DB task -> SUCCESS
└─ owner.acceptResult(this) // TaskRuntime.java:80
└─ OptimizingQueue.acceptResult // OptimizingQueue.java:438
└─ tableRuntime.beginCommitting() // OptimizingQueue.java:458
└─ TableRuntime.beginCommitting // TableRuntime.java:238
└─ persistUpdatingRuntime() // TableRuntime.java:451
❌ Lock wait timeout
```
When `persistUpdatingRuntime()` throws, the outer `invokeConsistency` in
`StatedPersistentBase` does this:
```java
// StatedPersistentBase.java:47
protected final void invokeConsistency(Runnable runnable) {
stateLock.lock();
Map<Field, Object> states = retainStates();
try {
doAsTransaction(runnable);
} catch (Throwable throwable) {
restoreStates(states); // <-- rolls back @StateField ON THE CURRENT
OBJECT ONLY
throw throwable;
} finally {
stateLock.unlock();
}
}
```
`restoreStates()` uses reflection and only touches fields annotated with
`@StateField` **on the `TaskRuntime` instance** that started the transaction.
That gives us this asymmetric rollback:
| State | Rolled back? | Result |
|---|---|---|
| `TaskRuntime.status` (SUCCESS → ACKED) | ✅ via `restoreStates` | task
looks "unfinished" in memory |
| DB row in `task_runtime` | ✅ `doAsTransaction` rolls it back | consistent
with memory |
| DB row in `table_runtime` (`optimizing_status_code`) | ❌ the UPDATE itself
failed | stays `*_OPTIMIZING` |
| `OptimizingProcess.status` (RUNNING) | ❌ not a `@StateField` of
`TaskRuntime` | stays `RUNNING` forever |
So from the optimizer's perspective the `completeTask` RPC failed; but the
task output has already been written to storage, and the optimizer typically
won't *re-execute* the same rewrite task — it just gives up on that RPC.
Nothing on AMS side then re-drives `beginCommitting()`, and the process stays
`RUNNING` indefinitely.
---
#### 3. Why it's `Lock wait timeout` specifically
`table_runtime` is a hot single-row record: every status transition
(`beginCommitting`, `refresh`, `persistPendingInput`, quota updates…) all goes
through `persistUpdatingRuntime()` → the same `UPDATE ... WHERE table_id = ?`.
When another writer (e.g. `TableRuntimeRefreshExecutor`) holds the row lock,
the default `innodb_lock_wait_timeout` (50s) trips and InnoDB rolls the
`beginCommitting` transaction back.
This is **not a rare race** — it's a predictable collision under load.
---
#### 4. Existing evidence in the tree
The test
`TestDefaultOptimizingService#testReloadAllTasksCompletedNotYetCommitting`
already describes exactly this situation:
> Simulate: AMS crashes after persisting the last task as SUCCESS but before
`beginCommitting()` updates the table status to COMMITTING.
> DB state: process=RUNNING, all tasks=SUCCESS, table=\*\_OPTIMIZING
That test verifies recovery **only when AMS restarts** and `reload()` runs.
Issue #4172 is the same state — but without a restart nothing drives recovery.
---
#### 5. Suggested fix directions
**A. Periodic self-heal (lowest risk, recommended)**
Extend the `reload()` recovery rule ("if all tasks succeeded but table is
not `COMMITTING`, call `beginCommitting()`") into the periodic
`TableRuntimeRefreshExecutor`. Needs to be idempotent and re-check
`allTasksPrepared()` under the process lock.
**B. Bounded retry + decoupling**
Don't let `beginCommitting()` run inside the `TaskRuntime` transaction.
After persisting the task as SUCCESS, attempt `beginCommitting()` with a small
bounded retry (e.g. 3× with backoff) to absorb lock-wait blips; on exhaustion,
record a pending flag and rely on (A) to finish the job.
**C. Symmetric rollback**
If `beginCommitting()` fails, explicitly revert the task back to `ACKED` and
surface an error that the optimizer *will* retry as `completeTask`. Downside:
current optimizer retry semantics don't guarantee a re-`completeTask` on the
same task result.
I'd suggest pairing **A** (correctness backstop) with **B** (fast path),
since (A) alone also covers other future failure paths into the same hang state.
Happy to put up a PR — planning to start with option A since it reuses the
existing, tested recovery logic. Let me know if any maintainer prefers a
different direction before I start.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]