Skip to content

Commit 35abf5c

Browse files
backport of commit adf6eae (#36192)
Co-authored-by: Brandon Croft <[email protected]>
1 parent 1d13e0c commit 35abf5c

File tree

3 files changed

+174
-1
lines changed

3 files changed

+174
-1
lines changed

internal/backend/remote/backend_state.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ type remoteClient struct {
3131
forcePush bool
3232
}
3333

34+
// errorUnlockFailed is used within a retry loop to identify a non-retryable
35+
// workspace unlock error
36+
type errorUnlockFailed struct {
37+
innerError error
38+
}
39+
40+
func (e errorUnlockFailed) FatalError() error {
41+
return e.innerError
42+
}
43+
44+
func (e errorUnlockFailed) Error() string {
45+
return e.innerError.Error()
46+
}
47+
48+
var _ Fatal = errorUnlockFailed{}
49+
3450
// Get the remote state.
3551
func (r *remoteClient) Get() (*remote.Payload, error) {
3652
ctx := context.Background()
@@ -202,7 +218,20 @@ func (r *remoteClient) Unlock(id string) error {
202218
}
203219

204220
// Unlock the workspace.
205-
_, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID)
221+
// Unlock the workspace.
222+
err := RetryBackoff(ctx, func() error {
223+
_, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID)
224+
if err != nil {
225+
if errors.Is(err, tfe.ErrWorkspaceLockedStateVersionStillPending) {
226+
// This is a retryable error.
227+
return err
228+
}
229+
// This will not be retried
230+
return &errorUnlockFailed{innerError: err}
231+
}
232+
return nil
233+
})
234+
206235
if err != nil {
207236
lockErr.Err = err
208237
return lockErr

internal/backend/remote/backend_state_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ package remote
66
import (
77
"bytes"
88
"os"
9+
"strings"
910
"testing"
1011

1112
"github.com/hashicorp/terraform/internal/backend"
1213
"github.com/hashicorp/terraform/internal/cloud"
1314
"github.com/hashicorp/terraform/internal/states"
1415
"github.com/hashicorp/terraform/internal/states/remote"
1516
"github.com/hashicorp/terraform/internal/states/statefile"
17+
"github.com/hashicorp/terraform/internal/states/statemgr"
1618
)
1719

1820
func TestRemoteClient_impl(t *testing.T) {
@@ -41,6 +43,47 @@ func TestRemoteClient_stateLock(t *testing.T) {
4143
remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client)
4244
}
4345

46+
func TestRemoteClient_Unlock_invalidID(t *testing.T) {
47+
b, bCleanup := testBackendDefault(t)
48+
defer bCleanup()
49+
50+
s1, err := b.StateMgr(backend.DefaultStateName)
51+
if err != nil {
52+
t.Fatalf("expected no error, got %v", err)
53+
}
54+
55+
err = s1.Unlock("no")
56+
if err == nil {
57+
t.Fatal("expected error, got nil")
58+
}
59+
60+
if !strings.Contains(err.Error(), "does not match existing lock ID") {
61+
t.Fatalf("expected erroor containing \"does not match existing lock ID\", got %v", err)
62+
}
63+
}
64+
65+
func TestRemoteClient_Unlock(t *testing.T) {
66+
b, bCleanup := testBackendDefault(t)
67+
defer bCleanup()
68+
69+
s1, err := b.StateMgr(backend.DefaultStateName)
70+
if err != nil {
71+
t.Fatalf("expected no error, got %v", err)
72+
}
73+
74+
id, err := s1.Lock(&statemgr.LockInfo{
75+
ID: "test",
76+
})
77+
if err != nil {
78+
t.Fatalf("expected no error, got %v", err)
79+
}
80+
81+
err = s1.Unlock(id)
82+
if err != nil {
83+
t.Fatalf("expected no error, got %v", err)
84+
}
85+
}
86+
4487
func TestRemoteClient_Put_withRunID(t *testing.T) {
4588
// Set the TFE_RUN_ID environment variable before creating the client!
4689
if err := os.Setenv("TFE_RUN_ID", cloud.GenerateID("run-")); err != nil {

internal/backend/remote/retry.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package remote
5+
6+
import (
7+
"context"
8+
"log"
9+
"sync/atomic"
10+
"time"
11+
)
12+
13+
// Fatal implements a RetryBackoff func return value that, if encountered,
14+
// signals that the func should not be retried. In that case, the error
15+
// returned by the interface method will be returned by RetryBackoff
16+
type Fatal interface {
17+
FatalError() error
18+
}
19+
20+
// NonRetryableError is a simple implementation of Fatal that wraps an error
21+
type NonRetryableError struct {
22+
InnerError error
23+
}
24+
25+
// FatalError returns the inner error, but also implements Fatal, which
26+
// signals to RetryBackoff that a non-retryable error occurred.
27+
func (e NonRetryableError) FatalError() error {
28+
return e.InnerError
29+
}
30+
31+
// Error returns the inner error string
32+
func (e NonRetryableError) Error() string {
33+
return e.InnerError.Error()
34+
}
35+
36+
var (
37+
initialBackoffDelay = time.Second
38+
maxBackoffDelay = 3 * time.Second
39+
)
40+
41+
// RetryBackoff retries function f until nil or a FatalError is returned.
42+
// RetryBackoff only returns an error if the context is in error or if a
43+
// FatalError was encountered.
44+
func RetryBackoff(ctx context.Context, f func() error) error {
45+
// doneCh signals that the routine is done and sends the last error
46+
var doneCh = make(chan struct{})
47+
var errVal atomic.Value
48+
type errWrap struct {
49+
E error
50+
}
51+
52+
go func() {
53+
// the retry delay between each attempt
54+
var delay time.Duration = 0
55+
defer close(doneCh)
56+
57+
for {
58+
select {
59+
case <-ctx.Done():
60+
return
61+
case <-time.After(delay):
62+
}
63+
64+
err := f()
65+
switch e := err.(type) {
66+
case nil:
67+
return
68+
case Fatal:
69+
errVal.Store(errWrap{e.FatalError()})
70+
return
71+
}
72+
73+
delay *= 2
74+
if delay == 0 {
75+
delay = initialBackoffDelay
76+
}
77+
78+
delay = min(delay, maxBackoffDelay)
79+
80+
log.Printf("[WARN] retryable error: %q, delaying for %s", err, delay)
81+
}
82+
}()
83+
84+
// Wait until done or deadline
85+
select {
86+
case <-doneCh:
87+
case <-ctx.Done():
88+
}
89+
90+
err, hadErr := errVal.Load().(errWrap)
91+
var lastErr error
92+
if hadErr {
93+
lastErr = err.E
94+
}
95+
96+
if ctx.Err() != nil {
97+
return ctx.Err()
98+
}
99+
100+
return lastErr
101+
}

0 commit comments

Comments
 (0)