| index 7a62d261b..a9c12190e 100644 |
| --- a/internal/lease/cleaning_test.go |
| +++ b/internal/lease/cleaning_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "go/ast" |
| "go/token" |
| "go/types" |
| @@ -295,7 +296,7 @@ func TestUnmarkWritesALiftedTombstoneThatNeverRecurs(t *testing.T) { |
| if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| l, raw, etag := s.current(t) |
| @@ -306,7 +307,7 @@ func TestUnmarkWritesALiftedTombstoneThatNeverRecurs(t *testing.T) { |
| t.Fatalf("the unmark's body recurs: %s (before %s)", raw, beforeRaw) |
| } |
| // A second unmark finds no mark and writes anyway. |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| if l, _, _ := s.current(t); l.Cleanups != before.Cleanups+2 || !lease.IsLiftedTombstone(l.Owner, l.ExpiresAt) { |
| @@ -316,7 +317,7 @@ func TestUnmarkWritesALiftedTombstoneThatNeverRecurs(t *testing.T) { |
| if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, second); err != nil { |
| t.Fatal(err) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, second); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, second); err != nil { |
| t.Fatal(err) |
| } |
| vs, err := s.ListVersions(ctx, "data", tombLockKy) |
| @@ -362,8 +363,12 @@ func TestUnmarkWritesALiftedTombstoneThatNeverRecurs(t *testing.T) { |
| c.set(t, s) |
| raw, _ := s.Current("data", tombLockKy) |
| writes := s.writeCount() |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| - t.Fatalf("UnmarkCleaning = %v, want nil", err) |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if err != nil { |
| + t.Fatalf("UnmarkCleaning = %v, want an abstention", err) |
| + } |
| + if _, ok := ans.(lease.Abstained); !ok { |
| + t.Fatalf("UnmarkCleaning answered %#v, want Abstained", ans) |
| } |
| requireUntouched(t, s, writes, raw) |
| }) |
| @@ -402,7 +407,7 @@ func TestDelayedMarkCannotLandAfterTheUnmark(t *testing.T) { |
| t.Fatalf("the resend's fields differ: %+v and %+v", a1, a2) |
| } |
| |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| l, unmarked, etag := s.current(t) |
| @@ -443,7 +448,7 @@ func TestUnmarkSettlesAnUncertainMark(t *testing.T) { |
| if _, now, _ := s.current(t); !bytes.Equal(now, tBody) { |
| t.Fatalf("lock = %s, want the lifted tombstone", now) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| l, after, _ := s.current(t) |
| @@ -463,7 +468,7 @@ func TestUnmarkSettlesAnUncertainMark(t *testing.T) { |
| liftedAt(t, s, 4) |
| release := uncertainMark(t, s, h) |
| s.beforeWrite(func(string) { release() }) |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); !errors.Is(err, lease.ErrTombstoneChanged) { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); !errors.Is(err, lease.ErrTombstoneChanged) { |
| t.Fatalf("UnmarkCleaning = %v, want ErrTombstoneChanged", err) |
| } |
| if st := h.Requests(tombLockKy)[1].Status; st != http.StatusOK { |
| @@ -472,7 +477,7 @@ func TestUnmarkSettlesAnUncertainMark(t *testing.T) { |
| if l, _, _ := s.current(t); l.Owner != lease.CleaningOwner(cleanID) { |
| t.Fatalf("lock = %+v, want the late mark", l) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| if l, _, _ := s.current(t); l.Owner != tombMove || !lease.IsLiftedTombstone(l.Owner, l.ExpiresAt) || l.Cleanups != 1 { |
| @@ -486,6 +491,10 @@ func TestLockWritesAreSingleAttemptsEachWithItsOwnWriteID(t *testing.T) { |
| t.Run("every send reaches the store once", func(t *testing.T) { |
| s, h := holdFixture(t) |
| liftedAt(t, s, 4) |
| + // A long attempt deadline against an instant 500: the SDK's retry |
| + // backoff (up to 1 s, then 2 s) fits inside it, so a retryer left |
| + // on would send the first body again within the attempt. |
| + t.Cleanup(lease.SetLockAttemptTimeoutForTest(10 * time.Second)) |
| s.FailOn = func(op, key string) error { |
| if op == "put" && key == tombLockKy { |
| return errors.New("injected 500") |
| @@ -499,9 +508,11 @@ func TestLockWritesAreSingleAttemptsEachWithItsOwnWriteID(t *testing.T) { |
| t.Fatalf("MarkCleaning = %v, want ErrLockWriteUncertain", err) |
| } |
| reqs := h.Requests(tombLockKy)[1:] |
| - // Sent at 0 s and after 1 s, then the context ends during the 2 s |
| - // wait. The SDK's default retryer would have sent the first three |
| - // times. |
| + // Our sends go at 0 s and after 1 s, and the context ends during the |
| + // 2 s wait. Each body (its own write_id) reaches the store exactly |
| + // once: with the SDK's default retryer the first body would arrive |
| + // again inside its 10 s attempt, and this count and the distinct |
| + // bodies below would both fail. |
| if len(reqs) != 2 || s.writeCount()-writes != 2 { |
| t.Fatalf("%d sends (%d writes), want 2", len(reqs), s.writeCount()-writes) |
| } |
| @@ -668,7 +679,7 @@ func TestDelayedTombstoneCreationCannotRestoreAMarkCondition(t *testing.T) { |
| if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, o); err != nil { |
| t.Fatalf("MarkCleaning = %v", err) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, o); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, o); err != nil { |
| t.Fatal(err) |
| } |
| if l, _, _ := s.current(t); l.Cleanups != 1 { |
| @@ -796,6 +807,9 @@ func TestEveryLockWriteOfOursCarriesItsOwnWriteID(t *testing.T) { |
| if !a.writeIDDrawnPerSend() { |
| t.Error("putLockUntilAnswered does not draw WriteID from newWriteID() inside its loop before it marshals") |
| } |
| + if !a.putHasNopRetryer() { |
| + t.Error("putLockUntilAnswered's PutObject does not pass the option o.Retryer = aws.NopRetryer{}: the SDK would resend a body itself") |
| + } |
| |
| // In behaviour: a lift, a mark with one held attempt and an unmark send |
| // pairwise distinct bodies, each with its own write_id. |
| @@ -806,7 +820,7 @@ func TestEveryLockWriteOfOursCarriesItsOwnWriteID(t *testing.T) { |
| if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| - if err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + if _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| t.Fatal(err) |
| } |
| release() |
| @@ -1053,3 +1067,262 @@ func (a *leaseAudit) writeIDDrawnPerSend() bool { |
| }) |
| return ok |
| } |
| + |
| +// putHasNopRetryer checks that putLockUntilAnswered's PutObject call passes |
| +// an option function that sets the client's Retryer to aws.NopRetryer{}, so |
| +// one send is one HTTP request. |
| +func (a *leaseAudit) putHasNopRetryer() bool { |
| + fd := a.decls["putLockUntilAnswered"] |
| + if fd == nil { |
| + return false |
| + } |
| + ok, puts := false, 0 |
| + ast.Inspect(fd.Body, func(n ast.Node) bool { |
| + call, isCall := n.(*ast.CallExpr) |
| + if !isCall { |
| + return true |
| + } |
| + if f := a.callee(call); f == nil || f.Name() != "PutObject" { |
| + return true |
| + } |
| + puts++ |
| + for _, arg := range call.Args[1:] { |
| + lit, isLit := arg.(*ast.FuncLit) |
| + if !isLit { |
| + continue |
| + } |
| + ast.Inspect(lit.Body, func(n ast.Node) bool { |
| + as, isAssign := n.(*ast.AssignStmt) |
| + if !isAssign || len(as.Lhs) != 1 || len(as.Rhs) != 1 { |
| + return true |
| + } |
| + sel, isSel := as.Lhs[0].(*ast.SelectorExpr) |
| + if isSel && sel.Sel.Name == "Retryer" && types.TypeString(a.info.TypeOf(as.Rhs[0]), nil) == "github.com/aws/aws-sdk-go-v2/aws.NopRetryer" { |
| + ok = true |
| + } |
| + return true |
| + }) |
| + } |
| + return true |
| + }) |
| + return ok && puts == 1 |
| +} |
| + |
| +// UnmarkCleaning says what it did beside its error (fix round 1, Important |
| +// 2): Unmarked when its own write landed, Settled when the lock is already |
| +// past our mark without that write (a 412 read back as an earlier attempt of |
| +// ours, or a key gone at the write), and Abstained, with what it found, when |
| +// there is nothing of ours to unmark. An abstention is never ErrNotOurLock: |
| +// a release loop that retried it would retry a lock that never changes. |
| +func TestUnmarkAnswersEachOutcome(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("Unmarked: the mark lifted by our write", func(t *testing.T) { |
| + s, _ := holdFixture(t) |
| + liftedAt(t, s, 4) |
| + if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if _, ok := ans.(lease.Unmarked); err != nil || !ok { |
| + t.Fatalf("UnmarkCleaning = %#v, %v; want Unmarked", ans, err) |
| + } |
| + // The settling write over this move's lifted tombstone is a write |
| + // of ours too. |
| + ans, err = lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if _, ok := ans.(lease.Unmarked); err != nil || !ok { |
| + t.Fatalf("a resumed UnmarkCleaning = %#v, %v; want Unmarked", ans, err) |
| + } |
| + }) |
| + t.Run("Settled: a 412 read back as an earlier attempt of ours", func(t *testing.T) { |
| + s, h := holdFixture(t) |
| + liftedAt(t, s, 4) |
| + if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + var release func() |
| + s.beforeWrite(func(string) { s.beforeWrite(func(string) { release() }) }) |
| + release = hold(t, h, 1) |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if _, ok := ans.(lease.Settled); err != nil || !ok { |
| + t.Fatalf("UnmarkCleaning = %#v, %v; want Settled", ans, err) |
| + } |
| + if l, _, _ := s.current(t); l.Owner != tombMove || l.Cleanups != 1 { |
| + t.Fatalf("lock = %+v", l) |
| + } |
| + }) |
| + t.Run("Settled: the key gone at the write, 404 as AWS answers it", func(t *testing.T) { |
| + var gone atomic.Bool |
| + s := tombFixtureWrap(t, func(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/lock.json") && gone.Load() { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusNotFound) |
| + fmt.Fprint(w, "<Error><Code>NoSuchKey</Code><Message>NoSuchKey</Message></Error>") |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + }) |
| + liftedAt(t, s, 4) |
| + if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.beforeWrite(func(string) { s.BareDelete("data", tombLockKy); gone.Store(true) }) |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if _, ok := ans.(lease.Settled); err != nil || !ok { |
| + t.Fatalf("UnmarkCleaning = %#v, %v; want Settled", ans, err) |
| + } |
| + }) |
| + t.Run("Settled: the key gone at the write, 412 as other stores answer it", func(t *testing.T) { |
| + s, _ := holdFixture(t) |
| + liftedAt(t, s, 4) |
| + if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.beforeWrite(func(string) { s.BareDelete("data", tombLockKy) }) |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if _, ok := ans.(lease.Settled); err != nil || !ok { |
| + t.Fatalf("UnmarkCleaning = %#v, %v; want Settled", ans, err) |
| + } |
| + }) |
| + t.Run("Abstained names the lock it found", func(t *testing.T) { |
| + other := lease.CleaningOwner(cleanID2) |
| + for _, c := range []struct { |
| + name, owner string |
| + absent bool |
| + cleaning bool |
| + }{ |
| + {"absent", "", true, false}, |
| + {"another cleanup's mark", other, false, true}, |
| + {"another move's tombstone", tombOther, false, false}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, _ := holdFixture(t) |
| + if !c.absent { |
| + s.putLock(t, tombLoc, c.owner, 3, lease.CleaningExpiry) |
| + } |
| + ans, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + if err != nil { |
| + t.Fatalf("UnmarkCleaning = %v; an abstention is not an error", err) |
| + } |
| + a, ok := ans.(lease.Abstained) |
| + if !ok || a.Absent != c.absent || a.Owner != c.owner || lease.IsCleaningOwner(a.Owner) != c.cleaning || a.Lock == "" { |
| + t.Fatalf("UnmarkCleaning answered %#v", ans) |
| + } |
| + if !c.absent && !a.ExpiresAt.Equal(lease.CleaningExpiry) { |
| + t.Fatalf("expiry %s", a.ExpiresAt) |
| + } |
| + }) |
| + } |
| + }) |
| + t.Run("an uncertain write is an error and no answer", func(t *testing.T) { |
| + s, h := holdFixture(t) |
| + liftedAt(t, s, 4) |
| + hold(t, h, 1) |
| + uctx, cancel := context.WithTimeout(ctx, attempt+500*time.Millisecond) |
| + defer cancel() |
| + ans, err := lease.UnmarkCleaning(uctx, "data", tombLoc, tombRegion, owners) |
| + if !errors.Is(err, lease.ErrLockWriteUncertain) || ans != nil { |
| + t.Fatalf("UnmarkCleaning = %#v, %v", ans, err) |
| + } |
| + }) |
| +} |
| + |
| +// MarkCleaning's refusal names what it found as a typed error, so Task 4a |
| +// can tell another cleanup's mark (IsCleaningOwner) from a lock that is not |
| +// a cleanup's at all without reading the error text. |
| +func TestMarkRefusalIsTyped(t *testing.T) { |
| + ctx := context.Background() |
| + other := lease.CleaningOwner(cleanID2) |
| + for _, c := range []struct { |
| + name, owner string |
| + absent bool |
| + }{ |
| + {"another cleanup's mark", other, false}, |
| + {"another move's tombstone", tombOther, false}, |
| + {"absent", "", true}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, _ := holdFixture(t) |
| + if !c.absent { |
| + s.putLock(t, tombLoc, c.owner, 3, lease.CleaningExpiry) |
| + } |
| + err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners) |
| + var nl *lease.NotOurLockError |
| + if !errors.Is(err, lease.ErrNotOurLock) || !errors.As(err, &nl) { |
| + t.Fatalf("MarkCleaning = %v, want a *NotOurLockError", err) |
| + } |
| + if nl.Absent != c.absent || nl.Owner != c.owner || nl.Lock == "" || lease.IsCleaningOwner(nl.Owner) != (c.owner == other) { |
| + t.Fatalf("refusal %+v", nl) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// slowReads delays every GET of the lock by d while armed, in front of the |
| +// store: a caller whose context has no deadline must still end. |
| +func slowReadsFixture(t *testing.T, d time.Duration) (*tombStore, *atomic.Bool) { |
| + t.Helper() |
| + var armed atomic.Bool |
| + s := tombFixtureWrap(t, func(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/lock.json") && armed.Load() { |
| + time.Sleep(d) |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + }) |
| + return s, &armed |
| +} |
| + |
| +// The 10 s budget is per operation (fix round 1, minor 3): the lift, the |
| +// mark and the unmark each bound their reads and their writes together, |
| +// even under a context with no deadline. |
| +func TestLockOperationsHaveOneBudget(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("the reads are bounded", func(t *testing.T) { |
| + t.Cleanup(lease.SetLockOperationBudgetForTest(300 * time.Millisecond)) |
| + s, armed := slowReadsFixture(t, 2*time.Second) |
| + liftedAt(t, s, 4) |
| + armed.Store(true) |
| + for name, op := range map[string]func() error{ |
| + "mark": func() error { return lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners) }, |
| + "unmark": func() error { _, err := lease.UnmarkCleaning(ctx, "data", tombLoc, tombRegion, owners); return err }, |
| + "lift": func() error { |
| + _, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)) |
| + return err |
| + }, |
| + } { |
| + start := time.Now() |
| + err := op() |
| + if err == nil || time.Since(start) > 1500*time.Millisecond { |
| + t.Errorf("%s under a slow read: %v after %s, want an error within the budget", name, err, time.Since(start)) |
| + } |
| + } |
| + }) |
| + t.Run("one budget for the read and the write", func(t *testing.T) { |
| + // The read takes 1 s of a 1.5 s budget; the mark's first attempt is |
| + // held and times out at 1.2 s, and its resend would go at 2.2 s, |
| + // after the operation's budget. A budget per write (from 1 s to |
| + // 2.5 s) would let that resend land. |
| + t.Cleanup(lease.SetLockOperationBudgetForTest(1500 * time.Millisecond)) |
| + t.Cleanup(lease.SetLockAttemptTimeoutForTest(attempt)) |
| + var h *leasetest.Holder |
| + var armed atomic.Bool |
| + s := tombFixtureWrap(t, func(next http.Handler) http.Handler { |
| + h = leasetest.NewHolder(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/lock.json") && armed.CompareAndSwap(true, false) { |
| + time.Sleep(time.Second) |
| + } |
| + h.ServeHTTP(w, r) |
| + }) |
| + }) |
| + liftedAt(t, s, 4) |
| + armed.Store(true) |
| + hold(t, h, 1) |
| + if err := lease.MarkCleaning(ctx, "data", tombLoc, tombRegion, owners); !errors.Is(err, lease.ErrLockWriteUncertain) { |
| + t.Fatalf("MarkCleaning = %v, want ErrLockWriteUncertain at the operation's budget", err) |
| + } |
| + }) |
| +} |