Skip to content

Commit cbf8cfa

Browse files
authored
fix: requeue workflow on transient sync lock errors instead of failing (cherry-pick #16745 for 4.0) (#16792)
Signed-off-by: Alan Clucas <alan@clucas.org>
1 parent 1b1e5d9 commit cbf8cfa

7 files changed

Lines changed: 370 additions & 18 deletions

File tree

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ require (
300300
github.com/klauspost/compress v1.18.7 // indirect
301301
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
302302
github.com/kylelemons/godebug v1.1.0 // indirect
303-
github.com/lib/pq v1.10.9 // indirect
303+
github.com/lib/pq v1.10.9
304304
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
305305
github.com/lithammer/dedent v1.1.0 // indirect
306306
github.com/mailru/easyjson v0.9.0 // indirect

‎util/sqldb/errors.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package sqldb
2+
3+
import (
4+
"errors"
5+
6+
"github.com/go-sql-driver/mysql"
7+
"github.com/lib/pq"
8+
)
9+
10+
// PostgreSQL transaction-conflict error codes: serialization_failure and
11+
// deadlock_detected. lib/pq v1.10 exports no code constants (the pqerror
12+
// subpackage arrived in a later release), so they are defined here.
13+
const (
14+
pqSerializationFailure pq.ErrorCode = "40001"
15+
pqDeadlockDetected pq.ErrorCode = "40P01"
16+
)
17+
18+
// mysqlErLockDeadlock is ER_LOCK_DEADLOCK: the transaction was chosen as the
19+
// deadlock victim and rolled back. go-sql-driver/mysql exports no error-number
20+
// constants. ER_LOCK_WAIT_TIMEOUT (1205) is deliberately not treated as a
21+
// conflict: it only rolls back the statement, not the transaction, and it
22+
// signals a held-too-long lock rather than a retryable ordering conflict.
23+
const mysqlErLockDeadlock = 1213
24+
25+
// IsSerializationConflict reports whether err is a transaction conflict
26+
// reported by the database driver itself: PostgreSQL serialization_failure
27+
// (40001) or deadlock_detected (40P01), or MySQL ER_LOCK_DEADLOCK (1213).
28+
// These are the errors where the database aborted the transaction because of
29+
// concurrent activity and the whole transaction can safely be retried.
30+
func IsSerializationConflict(err error) bool {
31+
var pqErr *pq.Error
32+
if errors.As(err, &pqErr) && (pqErr.Code == pqSerializationFailure || pqErr.Code == pqDeadlockDetected) {
33+
return true
34+
}
35+
return errors.Is(err, &mysql.MySQLError{Number: mysqlErLockDeadlock})
36+
}

‎util/sqldb/errors_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package sqldb
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/go-sql-driver/mysql"
9+
"github.com/lib/pq"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestIsSerializationConflict(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
err error
17+
want bool
18+
}{
19+
{"nil", nil, false},
20+
{"postgres serialization failure", &pq.Error{Code: pqSerializationFailure, Message: "could not serialize access"}, true},
21+
{"postgres deadlock detected", &pq.Error{Code: pqDeadlockDetected, Message: "deadlock detected"}, true},
22+
{"postgres wrapped", fmt.Errorf("commit: %w", &pq.Error{Code: pqSerializationFailure}), true},
23+
{"postgres unrelated code", &pq.Error{Code: "23505", Message: "duplicate key value"}, false},
24+
{"mysql deadlock", &mysql.MySQLError{Number: 1213, Message: "Deadlock found when trying to get lock; try restarting transaction"}, true},
25+
{"mysql wrapped", fmt.Errorf("commit: %w", &mysql.MySQLError{Number: 1213}), true},
26+
{"mysql lock wait timeout", &mysql.MySQLError{Number: 1205, Message: "Lock wait timeout exceeded; try restarting transaction"}, false},
27+
{"plain error mentioning deadlock", errors.New("deadlock detected"), false},
28+
{"plain error mentioning serialization", errors.New("could not serialize access"), false},
29+
}
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
assert.Equal(t, tt.want, IsSerializationConflict(tt.err))
33+
})
34+
}
35+
}

‎workflow/controller/operator.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import (
6262
"github.com/argoproj/argo-workflows/v4/workflow/controller/indexes"
6363
"github.com/argoproj/argo-workflows/v4/workflow/metrics"
6464
"github.com/argoproj/argo-workflows/v4/workflow/progress"
65+
wfsync "github.com/argoproj/argo-workflows/v4/workflow/sync"
6566
"github.com/argoproj/argo-workflows/v4/workflow/templateresolution"
6667
wfutil "github.com/argoproj/argo-workflows/v4/workflow/util"
6768
"github.com/argoproj/argo-workflows/v4/workflow/validate"
@@ -264,6 +265,20 @@ func (woc *wfOperationCtx) operate(ctx context.Context) {
264265
if woc.execWf.Spec.Synchronization != nil {
265266
acquired, wfUpdate, msg, failedLockName, err := woc.controller.syncManager.TryAcquire(ctx, woc.wf, "", woc.execWf.Spec.Synchronization)
266267
if err != nil {
268+
if wfsync.IsRetryableSyncError(err) || errorsutil.IsTransientErr(ctx, err) {
269+
// Transient failure in the lock backend (e.g. a database
270+
// serialization conflict that exhausted its in-place retries):
271+
// leave the workflow pending and try again on a later
272+
// reconcile instead of failing it.
273+
woc.log.WithError(err).WithField("lockName", failedLockName).Warn(ctx, "Transient failure acquiring the synchronization lock, requeueing")
274+
phase := woc.wf.Status.Phase
275+
if phase == wfv1.WorkflowUnknown {
276+
phase = wfv1.WorkflowPending
277+
}
278+
woc.markWorkflowPhase(ctx, phase, fmt.Sprintf("Waiting to acquire the synchronization lock. %v", err))
279+
woc.requeue()
280+
return
281+
}
267282
woc.log.WithField("lockName", failedLockName).Warn(ctx, "Failed to acquire the lock")
268283
woc.markWorkflowFailed(ctx, fmt.Sprintf("Failed to acquire the synchronization lock. %s", err.Error()))
269284
return
@@ -2239,6 +2254,15 @@ func (woc *wfOperationCtx) executeTemplate(ctx context.Context, nodeName string,
22392254
if processedTmpl.Synchronization != nil {
22402255
lockAcquired, wfUpdated, msg, failedLockName, err := woc.controller.syncManager.TryAcquire(ctx, woc.wf, woc.wf.NodeID(nodeName), processedTmpl.Synchronization)
22412256
if err != nil {
2257+
if wfsync.IsRetryableSyncError(err) || errorsutil.IsTransientErr(ctx, err) {
2258+
// Transient failure in the lock backend: leave the node pending
2259+
// and try again on a later reconcile instead of erroring it.
2260+
woc.requeue()
2261+
if node == nil {
2262+
node = woc.initializeExecutableNode(ctx, nodeName, wfutil.GetNodeType(processedTmpl), templateScope, processedTmpl, orgTmpl, opts.boundaryID, wfv1.NodePending, opts.nodeFlag, false, err.Error())
2263+
}
2264+
return node, nil
2265+
}
22422266
return woc.initializeNodeOrMarkError(ctx, node, nodeName, templateScope, orgTmpl, opts.boundaryID, opts.nodeFlag, err), err
22432267
}
22442268
if !lockAcquired {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package controller
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
10+
wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
11+
"github.com/argoproj/argo-workflows/v4/util/logging"
12+
"github.com/argoproj/argo-workflows/v4/workflow/sync"
13+
)
14+
15+
const wfWithWorkflowLevelSemaphore = `
16+
apiVersion: argoproj.io/v1alpha1
17+
kind: Workflow
18+
metadata:
19+
name: hello-world-wf-level-sem
20+
namespace: default
21+
spec:
22+
entrypoint: whalesay
23+
synchronization:
24+
semaphores:
25+
- configMapKeyRef:
26+
key: workflow
27+
name: my-config
28+
templates:
29+
- name: whalesay
30+
container:
31+
args:
32+
- "hello world"
33+
command:
34+
- cowsay
35+
image: "docker/whalesay:latest"
36+
`
37+
38+
// The semaphore's configmap does not exist in these tests, so TryAcquire
39+
// returns an error. A transient error must leave the workflow/node pending
40+
// and requeue; only a non-transient error may fail it.
41+
42+
func TestSyncErrorWorkflowLevel(t *testing.T) {
43+
operateWorkflow := func(t *testing.T) *wfOperationCtx {
44+
t.Helper()
45+
cancel, controller := newController(logging.TestContext(t.Context()))
46+
defer cancel()
47+
ctx := logging.TestContext(t.Context())
48+
controller.syncManager = sync.NewLockManager(ctx, controller.kubeclientset, controller.namespace, nil, getSyncLimitFunc(ctx, controller.kubeclientset), func(key string) {
49+
}, workflowExistenceFunc)
50+
51+
wf := wfv1.MustUnmarshalWorkflow(wfWithWorkflowLevelSemaphore)
52+
wf, err := controller.wfclientset.ArgoprojV1alpha1().Workflows(wf.Namespace).Create(ctx, wf, metav1.CreateOptions{})
53+
require.NoError(t, err)
54+
woc := newWorkflowOperationCtx(ctx, wf, controller)
55+
woc.operate(ctx)
56+
return woc
57+
}
58+
59+
t.Run("NonTransientErrorFailsWorkflow", func(t *testing.T) {
60+
woc := operateWorkflow(t)
61+
assert.Equal(t, wfv1.WorkflowFailed, woc.wf.Status.Phase)
62+
assert.Contains(t, woc.wf.Status.Message, "Failed to acquire the synchronization lock")
63+
})
64+
65+
t.Run("TransientErrorLeavesWorkflowPending", func(t *testing.T) {
66+
t.Setenv("TRANSIENT_ERROR_PATTERN", "failed to initialize semaphore")
67+
woc := operateWorkflow(t)
68+
assert.Equal(t, wfv1.WorkflowPending, woc.wf.Status.Phase)
69+
assert.Contains(t, woc.wf.Status.Message, "Waiting to acquire the synchronization lock")
70+
})
71+
}
72+
73+
func TestSyncTransientErrorNodeLevel(t *testing.T) {
74+
t.Setenv("TRANSIENT_ERROR_PATTERN", "failed to initialize semaphore")
75+
cancel, controller := newController(logging.TestContext(t.Context()))
76+
defer cancel()
77+
ctx := logging.TestContext(t.Context())
78+
controller.syncManager = sync.NewLockManager(ctx, controller.kubeclientset, controller.namespace, nil, getSyncLimitFunc(ctx, controller.kubeclientset), func(key string) {
79+
}, workflowExistenceFunc)
80+
81+
wf := wfv1.MustUnmarshalWorkflow(wfWithSemaphore)
82+
wf, err := controller.wfclientset.ArgoprojV1alpha1().Workflows(wf.Namespace).Create(ctx, wf, metav1.CreateOptions{})
83+
require.NoError(t, err)
84+
woc := newWorkflowOperationCtx(ctx, wf, controller)
85+
woc.operate(ctx)
86+
87+
assert.NotEqual(t, wfv1.WorkflowFailed, woc.wf.Status.Phase)
88+
require.NotEmpty(t, woc.wf.Status.Nodes)
89+
for _, node := range woc.wf.Status.Nodes {
90+
assert.Equal(t, wfv1.NodePending, node.Phase, "a transient sync error must leave the node pending, not errored")
91+
}
92+
}

‎workflow/sync/sync_manager.go‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/argoproj/argo-workflows/v4/config"
1919
wfv1 "github.com/argoproj/argo-workflows/v4/pkg/apis/workflow/v1alpha1"
2020
"github.com/argoproj/argo-workflows/v4/util/logging"
21+
"github.com/argoproj/argo-workflows/v4/util/sqldb"
2122
syncdb "github.com/argoproj/argo-workflows/v4/util/sync/db"
2223
)
2324

@@ -465,18 +466,16 @@ func (sm *Manager) TryAcquire(ctx context.Context, wf *wfv1.Workflow, nodeName s
465466
var updated bool
466467
var already bool
467468
var msg string
468-
// Backoff bounds: sm.lock is held for the whole loop, so cap each sleep
469-
// modestly. Jitter prevents a fleet of replicas from retrying in lockstep
470-
// after a shared conflict burst.
471-
backoff := wait.Backoff{
472-
Steps: 5,
473-
Duration: 10 * time.Millisecond,
474-
Factor: 2.0,
475-
Jitter: 0.5,
476-
Cap: 600 * time.Millisecond,
477-
}
469+
backoff := dbRetryBackoff
470+
// tryAcquireImpl mutates wf.Status.Synchronization in memory before the
471+
// transaction commits. Snapshot it and roll each failed attempt back, so
472+
// that attempts are independent and an error return leaves the caller's
473+
// status exactly as it was - otherwise an abort leaves a Holding entry
474+
// for a row the database rolled back, which would be persisted and then
475+
// failed as a stale hold on the next controller restart.
476+
syncStatus := wf.Status.Synchronization.DeepCopy()
478477
attempt := 0
479-
err = retry.OnError(backoff, isRetryableSyncError, func() error {
478+
err = retry.OnError(backoff, IsRetryableSyncError, func() error {
480479
attempt++
481480
sm.log.WithFields(logging.Fields{
482481
"holderKey": holderKey,
@@ -489,11 +488,12 @@ func (sm *Manager) TryAcquire(ctx context.Context, wf *wfv1.Workflow, nodeName s
489488
return implErr
490489
}, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: false})
491490
if txErr != nil {
491+
wf.Status.Synchronization = syncStatus.DeepCopy()
492492
sm.log.WithFields(logging.Fields{
493493
"holderKey": holderKey,
494494
"attempt": attempt,
495495
"error": txErr,
496-
"retryable": isRetryableSyncError(txErr),
496+
"retryable": IsRetryableSyncError(txErr),
497497
}).Info(ctx, "TryAcquire - transaction failed")
498498
}
499499
return txErr
@@ -506,14 +506,32 @@ func (sm *Manager) TryAcquire(ctx context.Context, wf *wfv1.Workflow, nodeName s
506506
return sm.tryAcquireImpl(ctx, wf, nil, holderKey, failedLockName, syncItems, lockKeys)
507507
}
508508

509-
// isRetryableSyncError reports whether a TryAcquire transaction failure should
510-
// be retried. Matches PostgreSQL SERIALIZABLE conflict (40001), deadlock
511-
// (40P01), and explicit rollback messages by substring against the driver's
512-
// text.
513-
func isRetryableSyncError(err error) bool {
509+
// dbRetryBackoff bounds the in-place retries of the TryAcquire transaction.
510+
// sm.lock is held for the whole loop, so each sleep is capped modestly. Jitter
511+
// prevents a fleet of replicas from retrying in lockstep after a shared
512+
// conflict burst. An error that survives these retries is surfaced to the
513+
// caller, which classifies it (sqldb.IsSerializationConflict, IsTransientErr)
514+
// and requeues the workflow rather than failing it.
515+
var dbRetryBackoff = wait.Backoff{
516+
Steps: 5,
517+
Duration: 10 * time.Millisecond,
518+
Factor: 2.0,
519+
Jitter: 0.5,
520+
Cap: 600 * time.Millisecond,
521+
}
522+
523+
// IsRetryableSyncError reports whether a TryAcquire transaction failure should
524+
// be retried. A conflict reported by the database driver itself is always
525+
// retryable; the substring match against the driver's text (serialization
526+
// conflicts, deadlocks, explicit rollbacks) remains as a fallback for errors
527+
// that arrive with the driver type stringified away by an intermediate layer.
528+
func IsRetryableSyncError(err error) bool {
514529
if err == nil {
515530
return false
516531
}
532+
if sqldb.IsSerializationConflict(err) {
533+
return true
534+
}
517535
s := strings.ToLower(err.Error())
518536
return strings.Contains(s, "serialization") ||
519537
strings.Contains(s, "dependencies") ||

0 commit comments

Comments
 (0)