Skip to content

Commit 0ce4862

Browse files
yan-3005claude
andauthored
fix(governance-workflows): retry the synchronous task-complete on a deadlock (#32530)
Resolving a workflow user task calls Flowable's taskService.complete() inline on the request thread. Under concurrent DAR-workflow writes (parallel resolves plus the async executor advancing other instances on the shared process definition), that command can lose an InnoDB deadlock race (MySQL errno 1213) while flushing its ACT_RU_* deletes — surfacing to the client as a spurious 409 on the transition. READ_COMMITTED already removes the gap-lock class; this is a record/FK-lock-ordering cycle that isolation level cannot prevent. Flowable's async executor already retries these deadlocks automatically; the synchronous resolve path was the one caller that did not. Wrap the taskService.complete() command in the existing DeadlockRetry — the same helper already guarding runtimeService.startProcessInstanceById(). The retry scope is the single self-contained Flowable command: when InnoDB rolls the transaction back it has already released every lock, so the replay runs in a fresh transaction with nothing held while it backs off. DeadlockRetry only retries a genuine deadlock (errno 1213/1205, SQLState 40001/40P01); any other failure, including a not-yet-committed task, propagates on the first attempt unchanged. Adds a DeadlockRetry.run(Runnable) overload for void commands and a unit test covering success, deadlock-then-success, non-deadlock propagation, attempt bounding, and the deadlock predicate. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1b677cc commit 0ce4862

3 files changed

Lines changed: 155 additions & 14 deletions

File tree

openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,12 +1002,12 @@ private boolean resolveTaskWithFreshReads(UUID customTaskId, Map<String, Object>
10021002
"[WorkflowTask] Completing with variables: taskId='{}' vars={}",
10031003
task.getId(),
10041004
variablesValue);
1005-
taskService.complete(task.getId(), variablesValue);
1005+
DeadlockRetry.run(() -> taskService.complete(task.getId(), variablesValue));
10061006
},
10071007
() -> {
10081008
LOG.debug(
10091009
"[WorkflowTask] Completing without variables: taskId='{}'", task.getId());
1010-
taskService.complete(task.getId());
1010+
DeadlockRetry.run(() -> taskService.complete(task.getId()));
10111011
});
10121012
LOG.debug("[WorkflowTask] SUCCESS: Task '{}' resolved", customTaskId);
10131013
}
@@ -1247,7 +1247,7 @@ private boolean handleMultiApproval(
12471247
Object rejectValue =
12481248
resolveMultiApprovalResult(task.getProcessDefinitionId(), nodeName, false, Boolean.FALSE);
12491249
variables.put(resultVariable, rejectValue);
1250-
taskService.complete(task.getId(), variables);
1250+
DeadlockRetry.run(() -> taskService.complete(task.getId(), variables));
12511251
return true;
12521252
}
12531253

@@ -1260,7 +1260,7 @@ private boolean handleMultiApproval(
12601260
Object approveValue =
12611261
resolveMultiApprovalResult(task.getProcessDefinitionId(), nodeName, true, Boolean.TRUE);
12621262
variables.put(resultVariable, approveValue);
1263-
taskService.complete(task.getId(), variables);
1263+
DeadlockRetry.run(() -> taskService.complete(task.getId(), variables));
12641264
return true;
12651265
}
12661266

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DeadlockRetry.java

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@
1919
import lombok.extern.slf4j.Slf4j;
2020

2121
/**
22-
* Retry wrapper for JDBI {@code @Transaction}-annotated methods that can lose a deadlock race on
23-
* hot rows.
22+
* Retry wrapper for a self-contained unit of work that can lose a deadlock race on hot rows.
2423
*
25-
* <p>The retry scope is the full transaction: when JDBI rolls the transaction back on a deadlock,
26-
* we re-invoke the enclosing method so the entire unit of work replays in a fresh transaction. Do
27-
* not push this down into {@code CollectionDAO} — retrying one DAO statement outside its original
28-
* transaction context would leave earlier writes in that txn lost.
24+
* <p>The retry scope is the whole enclosing unit of work — a JDBI {@code @Transaction}-annotated
25+
* method, or a single self-contained Flowable command (e.g. {@code taskService.complete(...)},
26+
* {@code runtimeService.startProcessInstanceById(...)}). When the database rolls the transaction
27+
* back on a deadlock it has already released every lock it held, so re-invoking the enclosing
28+
* operation replays it atomically in a fresh transaction. Do not push this down into
29+
* {@code CollectionDAO} — retrying one DAO statement outside its original transaction context
30+
* would leave earlier writes in that txn lost.
2931
*
3032
* <p>Backoff: retries are synchronous when invoked via {@link Retry#executeSupplier(Supplier)} —
3133
* the calling thread waits between attempts according to the configured interval. This matches
3234
* the existing retry pattern in {@code SearchRetryUtil} so operators see consistent behaviour
3335
* across subsystems. Exponential base 50 ms × 2^(attempt-1) with 50% jitter — attempt 1 ≈ 25-75
34-
* ms, attempt 2 ≈ 50-150 ms, attempt 3 ≈ 100-300 ms.
36+
* ms, attempt 2 ≈ 50-150 ms, attempt 3 ≈ 100-300 ms. The wait is bounded and happens after the
37+
* transaction has been rolled back, so no database lock is held while the thread backs off.
3538
*/
3639
@Slf4j
3740
public final class DeadlockRetry {
@@ -57,13 +60,23 @@ public final class DeadlockRetry {
5760

5861
private DeadlockRetry() {}
5962

60-
/** Execute {@code operation} with deadlock retry. {@code operation} must open its own JDBI
61-
* transaction (typically via {@code @Transaction} on the method it delegates to) so each retry
62-
* runs in a fresh, atomic unit of work. */
63+
/** Execute {@code operation} with deadlock retry. {@code operation} must open its own
64+
* transaction (a JDBI {@code @Transaction} method, or a self-contained Flowable command that
65+
* commits on its own) so each retry runs in a fresh, atomic unit of work. */
6366
public static <T> T execute(Supplier<T> operation) {
6467
return RETRY.executeSupplier(operation);
6568
}
6669

70+
/** Run a void {@code operation} with deadlock retry — the {@link Runnable} equivalent of
71+
* {@link #execute(Supplier)} for a self-contained command with no return value. */
72+
public static void run(Runnable operation) {
73+
RETRY.executeSupplier(
74+
() -> {
75+
operation.run();
76+
return null;
77+
});
78+
}
79+
6780
/** {@code true} if {@code throwable} (or any cause in its chain) is a MySQL/Postgres deadlock or
6881
* lock-wait timeout that is safe to retry as a fresh transaction. */
6982
public static boolean isDeadlock(Throwable throwable) {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2024 Collate
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
*/
11+
12+
package org.openmetadata.service.jdbi3;
13+
14+
import static org.junit.jupiter.api.Assertions.assertEquals;
15+
import static org.junit.jupiter.api.Assertions.assertFalse;
16+
import static org.junit.jupiter.api.Assertions.assertSame;
17+
import static org.junit.jupiter.api.Assertions.assertThrows;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
19+
20+
import java.sql.SQLException;
21+
import java.util.concurrent.atomic.AtomicInteger;
22+
import org.junit.jupiter.api.Test;
23+
24+
/**
25+
* Behavioural tests for {@link DeadlockRetry}. The exception shape mirrors production: a runtime
26+
* exception (Flowable / MyBatis {@code PersistenceException}) wrapping a {@link SQLException} whose
27+
* cause chain carries the MySQL deadlock (errno 1213). A bare checked {@code SQLException} cannot
28+
* escape a {@code Supplier}/{@code Runnable}, so it would never exercise the real path.
29+
*/
30+
class DeadlockRetryTest {
31+
32+
private static final int MAX_ATTEMPTS = 4;
33+
34+
private static RuntimeException deadlock() {
35+
SQLException sql =
36+
new SQLException(
37+
"Deadlock found when trying to get lock; try restarting transaction", "40001", 1213);
38+
return new RuntimeException("### Error updating database", sql);
39+
}
40+
41+
@Test
42+
void executeReturnsWithoutRetryOnSuccess() {
43+
AtomicInteger calls = new AtomicInteger();
44+
String result =
45+
DeadlockRetry.execute(
46+
() -> {
47+
calls.incrementAndGet();
48+
return "ok";
49+
});
50+
assertEquals("ok", result);
51+
assertEquals(1, calls.get(), "no retry on the happy path");
52+
}
53+
54+
@Test
55+
void executeRetriesDeadlockThenSucceeds() {
56+
AtomicInteger calls = new AtomicInteger();
57+
String result =
58+
DeadlockRetry.execute(
59+
() -> {
60+
if (calls.incrementAndGet() < 3) {
61+
throw deadlock();
62+
}
63+
return "ok";
64+
});
65+
assertEquals("ok", result);
66+
assertEquals(3, calls.get(), "replays until the deadlock clears");
67+
}
68+
69+
@Test
70+
void executeDoesNotRetryNonDeadlock() {
71+
AtomicInteger calls = new AtomicInteger();
72+
IllegalStateException boom = new IllegalStateException("not a deadlock");
73+
IllegalStateException thrown =
74+
assertThrows(
75+
IllegalStateException.class,
76+
() ->
77+
DeadlockRetry.execute(
78+
() -> {
79+
calls.incrementAndGet();
80+
throw boom;
81+
}));
82+
assertSame(boom, thrown, "non-deadlock errors propagate unchanged");
83+
assertEquals(1, calls.get(), "no retry for a non-deadlock error");
84+
}
85+
86+
@Test
87+
void executeStopsAfterMaxAttemptsWhenDeadlockPersists() {
88+
AtomicInteger calls = new AtomicInteger();
89+
assertThrows(
90+
RuntimeException.class,
91+
() ->
92+
DeadlockRetry.execute(
93+
() -> {
94+
calls.incrementAndGet();
95+
throw deadlock();
96+
}));
97+
assertEquals(MAX_ATTEMPTS, calls.get(), "bounded at the configured max attempts");
98+
}
99+
100+
@Test
101+
void runReplaysVoidCommandOnDeadlock() {
102+
AtomicInteger calls = new AtomicInteger();
103+
DeadlockRetry.run(
104+
() -> {
105+
if (calls.incrementAndGet() < 2) {
106+
throw deadlock();
107+
}
108+
});
109+
assertEquals(2, calls.get(), "void command replays once then commits");
110+
}
111+
112+
@Test
113+
void isDeadlockRecognisesRetryableCodesAndMessage() {
114+
assertTrue(DeadlockRetry.isDeadlock(deadlock()), "errno 1213 in the cause chain");
115+
assertTrue(
116+
DeadlockRetry.isDeadlock(new SQLException("lock wait timeout", "HY000", 1205)),
117+
"MySQL lock-wait timeout");
118+
assertTrue(
119+
DeadlockRetry.isDeadlock(new SQLException("deadlock detected", "40P01")),
120+
"Postgres deadlock SQLState");
121+
assertTrue(
122+
DeadlockRetry.isDeadlock(
123+
new RuntimeException("Deadlock found when trying to get lock; try restarting")),
124+
"message match without a SQLException");
125+
assertFalse(DeadlockRetry.isDeadlock(new IllegalStateException("unrelated")), "not a deadlock");
126+
assertFalse(DeadlockRetry.isDeadlock(null), "null is not a deadlock");
127+
}
128+
}

0 commit comments

Comments
 (0)