Skip to content

Commit 03fbf9f

Browse files
Joibelisubasinghe
andauthored
fix: address semaphore/mutex unsoundness for Initalize (cherry-pick #16160 for 4.0) (#16252)
Signed-off-by: isubasinghe <isitha@pipekit.io> Signed-off-by: Alan Clucas <alan@clucas.org> Co-authored-by: Isitha Subasinghe <isitha@pipekit.io>
1 parent b604bfc commit 03fbf9f

8 files changed

Lines changed: 780 additions & 57 deletions

File tree

workflow/controller/controller.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,24 @@ func (wfc *WorkflowController) initManagers(ctx context.Context) error {
463463
return err
464464
}
465465

466-
wfc.syncManager.Initialize(ctx, wfList.Items)
466+
// A non-nil error means a recorded lock holder could not be re-established
467+
// (undecodable lock name, or an unavailable database session). This is fatal
468+
// by design: we fail closed rather than risk a silent double-acquire.
469+
staleHolds, err := wfc.syncManager.Initialize(ctx, wfList.Items)
470+
if err != nil {
471+
return err
472+
}
473+
// Stale holds are workflows whose recorded hold on a database-backed lock
474+
// the database no longer has (e.g. expired while the controller was down,
475+
// possibly acquired by someone else since). The database is the source of
476+
// truth, so these workflows must not keep running on a hold it does not
477+
// back: fail them; persistUpdates releases any locks they still hold.
478+
for _, stale := range staleHolds {
479+
woc := newWorkflowOperationCtx(ctx, stale.WF, wfc)
480+
wocCtx := logging.WithLogger(ctx, woc.log)
481+
woc.markWorkflowFailed(wocCtx, fmt.Sprintf("Failed to re-establish synchronization lock at controller startup: %s", stale.Reason))
482+
woc.persistUpdates(wocCtx)
483+
}
467484

468485
if err := wfc.throttler.Init(wfList.Items); err != nil {
469486
return err

workflow/sync/common.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ import (
77

88
type semaphore interface {
99
acquire(ctx context.Context, holderKey string, tx *transaction) (bool, error)
10+
// reacquire re-establishes a recorded holder at controller startup.
11+
//
12+
// For an in-memory lock it force-registers the holder, ignoring the current
13+
// limit, so the in-memory count reflects persisted reality even when recorded
14+
// holders exceed a (since lowered) limit - new acquisitions then correctly
15+
// wait until the count drains below the limit, rather than dropping a holder
16+
// (a double-acquire) or poisoning the lock over a routine limit change.
17+
//
18+
// For a database-backed lock the database is the single source of truth:
19+
// reacquire mutates nothing and only asserts the recorded hold still exists
20+
// there. An error means the hold could not be verified - either the held row
21+
// is gone (e.g. expired while the controller was down) or the database could
22+
// not be queried - and the caller fails the holding workflow.
23+
reacquire(ctx context.Context, holderKey string, tx *transaction) error
1024
checkAcquire(ctx context.Context, holderKey string, tx *transaction) (bool, bool, string)
1125
tryAcquire(ctx context.Context, holderKey string, tx *transaction) (bool, string, error)
1226
release(ctx context.Context, key string) bool

workflow/sync/database_semaphore.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,25 @@ func (s *databaseSemaphore) acquire(ctx context.Context, holderKey string, tx *t
377377
return false, nil
378378
}
379379

380+
// reacquire asserts at startup that the recorded holder still holds this lock
381+
// in the database. The database is the single source of truth for a
382+
// database-backed lock: the held row is durable and survives the controller
383+
// restart, so nothing is inserted or mutated here. A missing row means the
384+
// hold no longer exists - e.g. it was expired by ExpireInactiveLocks while the
385+
// controller was down and may since have been acquired by another holder - so
386+
// the workflow's recorded hold is stale and the caller fails the workflow
387+
// rather than resurrect a hold the database does not back.
388+
func (s *databaseSemaphore) reacquire(ctx context.Context, holderKey string, tx *transaction) error {
389+
holders, err := s.currentHoldersSession(ctx, *tx.db)
390+
if err != nil {
391+
return fmt.Errorf("could not verify hold on %s for %s: %w", s.longDBKey(), holderKey, err)
392+
}
393+
if !slices.Contains(holders, holderKey) {
394+
return fmt.Errorf("hold on %s for %s is not present in the database", s.longDBKey(), holderKey)
395+
}
396+
return nil
397+
}
398+
380399
func (s *databaseSemaphore) tryAcquire(ctx context.Context, holderKey string, tx *transaction) (bool, string, error) {
381400
logger := s.logger(ctx)
382401
acq, already, msg := s.checkAcquire(ctx, holderKey, tx)

workflow/sync/mutex_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ func TestMutexLock(t *testing.T) {
122122

123123
wfList, err := wfclientset.ArgoprojV1alpha1().Workflows("default").List(ctx, metav1.ListOptions{})
124124
require.NoError(t, err)
125-
syncManager.Initialize(ctx, wfList.Items)
125+
staleHolds, err := syncManager.Initialize(ctx, wfList.Items)
126+
require.NoError(t, err)
127+
require.Empty(t, staleHolds)
126128
assert.Len(t, syncManager.syncLockMap, 1)
127129
})
128130
t.Run("WfLevelMutexAcquireAndRelease", func(t *testing.T) {

workflow/sync/poison.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package sync
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
)
8+
9+
// poisonedLock is a sentinel lock installed into the Manager's syncLockMap when,
10+
// during Initialize, the controller cannot re-establish a holder that a Running
11+
// workflow's status claims to hold.
12+
//
13+
// The soundness invariant is: if a Workflow's status records that it is holding
14+
// a lock, the in-memory lock map must reflect that hold after Initialize.
15+
// Otherwise a racing workflow's TryAcquire would find the lock absent, create a
16+
// fresh one, and acquire a lock that is - per persisted state - already held.
17+
// For a mutex that means two workflows running concurrently under the same
18+
// mutex.
19+
//
20+
// Rather than silently dropping the holder (the previous behaviour), we install
21+
// this lock, which refuses every acquire and reports a poisoned-state message.
22+
// That message surfaces on the waiting node's synchronization status, marking
23+
// the node/workflow as blocked by a poisoned lock so an operator can intervene.
24+
//
25+
// The poison is in-memory only and is cleared on the next controller restart,
26+
// at which point Initialize re-evaluates: if the offending workflow is no longer
27+
// Running the lock is recreated clean; if it is still Running and still
28+
// unresolvable, it is poisoned again.
29+
type poisonedLock struct {
30+
name string
31+
reason string
32+
}
33+
34+
var _ semaphore = &poisonedLock{}
35+
36+
func newPoisonedLock(name, reason string) *poisonedLock {
37+
return &poisonedLock{name: name, reason: reason}
38+
}
39+
40+
func (p *poisonedLock) message() string {
41+
return fmt.Sprintf("lock %s is in a poisoned state: %s; manual intervention required", p.name, p.reason)
42+
}
43+
44+
func (p *poisonedLock) acquire(_ context.Context, _ string, _ *transaction) (bool, error) {
45+
return false, nil
46+
}
47+
48+
// reacquire is a no-op: a poisoned lock refuses all holds until restart. It
49+
// returns nil because the poison already protects the recorded hold; failing
50+
// the holding workflow on top of that would punish it for an unrelated
51+
// holder's poisoning.
52+
func (p *poisonedLock) reacquire(_ context.Context, _ string, _ *transaction) error {
53+
return nil
54+
}
55+
56+
func (p *poisonedLock) checkAcquire(_ context.Context, _ string, _ *transaction) (bool, bool, string) {
57+
return false, false, p.message()
58+
}
59+
60+
func (p *poisonedLock) tryAcquire(_ context.Context, _ string, _ *transaction) (bool, string, error) {
61+
return false, p.message(), nil
62+
}
63+
64+
func (p *poisonedLock) release(_ context.Context, _ string) bool { return false }
65+
66+
func (p *poisonedLock) getName() string { return p.name }
67+
68+
func (p *poisonedLock) addToQueue(_ context.Context, _ string, _ int32, _ time.Time) error {
69+
return nil
70+
}
71+
72+
func (p *poisonedLock) removeFromQueue(_ context.Context, _ string) error { return nil }
73+
74+
func (p *poisonedLock) getCurrentHolders(_ context.Context) ([]string, error) { return nil, nil }
75+
76+
func (p *poisonedLock) getCurrentPending(_ context.Context) ([]string, error) { return nil, nil }
77+
78+
func (p *poisonedLock) getLimit(_ context.Context) int { return 0 }
79+
80+
func (p *poisonedLock) probeWaiting(_ context.Context) {}
81+
82+
// lock returns true so that tryAcquireImpl proceeds to checkAcquire, which
83+
// returns the poisoned-state message rather than a generic "failed to lock()".
84+
func (p *poisonedLock) lock(_ context.Context) bool { return true }
85+
86+
func (p *poisonedLock) unlock(_ context.Context) {}

workflow/sync/semaphore.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,24 @@ func (s *prioritySemaphore) acquire(_ context.Context, holderKey string, _ *tran
175175
return false, nil
176176
}
177177

178+
// reacquire re-establishes a recorded holder at startup, ignoring the limit. It
179+
// always registers the holder, even when the recorded holders already exceed the
180+
// current limit (e.g. the limit was lowered while held). The weighted semaphore
181+
// is capped at the limit, so a slot is only taken when one is free; the excess is
182+
// tracked solely in lockHolder, exactly as a downward resize leaves it. release()
183+
// already tolerates len(lockHolder) > limit and only frees a weighted slot once
184+
// the count drops below the limit, so new acquisitions wait until every recorded
185+
// holder has drained. It never fails: the in-memory map is the source of truth
186+
// here, so registering the holder is always possible.
187+
func (s *prioritySemaphore) reacquire(_ context.Context, holderKey string, _ *transaction) error {
188+
if _, ok := s.lockHolder[holderKey]; ok {
189+
return nil
190+
}
191+
s.semaphore.TryAcquire(1) // best effort: take a slot if one is free
192+
s.lockHolder[holderKey] = true
193+
return nil
194+
}
195+
178196
func isSameWorkflowNodeKeys(firstKey, secondKey string) bool {
179197
firstItems := strings.Split(firstKey, "/")
180198
secondItems := strings.Split(secondKey, "/")

0 commit comments

Comments
 (0)