| index 363acaa68..9dc522871 100644 |
| --- a/internal/lease/tombstone_test.go |
| +++ b/internal/lease/tombstone_test.go |
| @@ -2,10 +2,8 @@ package lease_test |
| |
| import ( |
| "context" |
| - "crypto/md5" |
| "encoding/json" |
| "errors" |
| - "fmt" |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| @@ -24,11 +22,10 @@ const ( |
| tombRegion = "test-region" |
| ) |
| |
| -// tombStore is leasetest's S3 server behind a wrapper that does two things the |
| -// server does not: it enforces If-Match on DELETE (as S3 does; the leaser's |
| -// release depends on it), and it runs a hook in the window between the |
| -// code under test's read of lock.json and its conditional write, so a race is |
| -// a request ordering, not a sleep. |
| +// tombStore is leasetest's S3 server behind a wrapper that runs a hook in the |
| +// window between the code under test's read of lock.json and its conditional |
| +// write, so a race is a request ordering, not a sleep. The conditions |
| +// themselves (If-Match, If-None-Match) are leasetest's own. |
| type tombStore struct { |
| *leasetest.VersionedBucket |
| mu sync.Mutex |
| @@ -60,19 +57,6 @@ func tombFixture(t *testing.T) *tombStore { |
| if hook != nil { |
| hook(r.Method) |
| } |
| - if r.Method == http.MethodDelete { |
| - match := strings.Trim(r.Header.Get("If-Match"), `"`) |
| - cur, ok := bucket.Current("data", key) |
| - switch { |
| - case match == "": |
| - case !ok: |
| - tombXMLErr(w, http.StatusNotFound, "NoSuchKey") |
| - return |
| - case fmt.Sprintf("%x", md5.Sum(cur)) != match: |
| - tombXMLErr(w, http.StatusPreconditionFailed, "PreconditionFailed") |
| - return |
| - } |
| - } |
| } |
| inner.ServeHTTP(w, r) |
| })) |
| @@ -87,10 +71,10 @@ func tombFixture(t *testing.T) *tombStore { |
| return s |
| } |
| |
| -func tombXMLErr(w http.ResponseWriter, status int, code string) { |
| - w.Header().Set("Content-Type", "application/xml") |
| - w.WriteHeader(status) |
| - fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>%s</Code><Message>%s</Message></Error>`, code, code) |
| +func (s *tombStore) methods() []string { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return append([]string(nil), s.writes...) |
| } |
| |
| func (s *tombStore) writeCount() int { |
| @@ -208,16 +192,91 @@ func TestTombstoneNeverReplacesALiveHolder(t *testing.T) { |
| } |
| } |
| |
| -// A holder whose expiry passed less than RenewMargin ago still counts as |
| -// holding, as LockState reads it. |
| -func TestTombstoneRespectsTheRenewMargin(t *testing.T) { |
| +// Free means what Acquire means by it: the expiry has passed. There is no |
| +// RenewMargin grace, so a lease that lapsed a moment ago is taken by Tombstone |
| +// exactly as lease.Acquire would take it. The two are run side by side on |
| +// identical locks so they cannot drift. |
| +func TestTombstoneAndAcquireAgreeOnFree(t *testing.T) { |
| + ctx := context.Background() |
| + for name, tc := range map[string]struct { |
| + expires time.Time |
| + free bool |
| + }{ |
| + "expired a second ago": {time.Now().Add(-time.Second), true}, |
| + "expired within the margin": {time.Now().Add(-lease.RenewMargin / 2), true}, |
| + "expires in a minute": {time.Now().Add(time.Minute), false}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + s := tombFixture(t) |
| + s.putLock(t, tombLoc, "box-a:1", 3, tc.expires) |
| + _, before, _ := s.lock(t, tombLoc) |
| + acq, acqErr := lease.Acquire(ctx, "data", tombLoc, tombRegion) |
| + if acqErr == nil { |
| + _ = acq.Release(ctx) |
| + } |
| + // Put the same lock back for Tombstone. |
| + s.putLock(t, tombLoc, "box-a:1", 3, tc.expires) |
| + tombErr := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour) |
| + if (acqErr == nil) != tc.free || (tombErr == nil) != tc.free { |
| + t.Fatalf("free %v: Acquire err %v, Tombstone err %v", tc.free, acqErr, tombErr) |
| + } |
| + if !tc.free { |
| + wantHeld(t, tombErr, "box-a:1") |
| + if _, after, _ := s.lock(t, tombLoc); string(after) != string(before) { |
| + t.Fatal("a live lease was replaced") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// An own tombstone that has expired is free like any other: Tombstone writes |
| +// a fresh one rather than reporting the dead one held. |
| +func TestTombstoneRewritesItsOwnExpiredTombstone(t *testing.T) { |
| s := tombFixture(t) |
| - s.putLock(t, tombLoc, "box-a:1", 3, time.Now().Add(-lease.RenewMargin/2)) |
| - _, before, _ := s.lock(t, tombLoc) |
| + s.putLock(t, tombLoc, tombMove, 4, time.Now().Add(-time.Second)) |
| + if err := lease.Tombstone(context.Background(), "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| + t.Fatal(err) |
| + } |
| + l, _, _ := s.lock(t, tombLoc) |
| + if l.Owner != tombMove || l.Generation != 5 { |
| + t.Fatalf("lock = %+v, want owner %s at generation 5", l, tombMove) |
| + } |
| + near(t, l.ExpiresAt, time.Hour) |
| +} |
| + |
| +// The write loses its condition and AcquireLease cannot read a winner back: |
| +// there is no holder to name, so the answer is ErrTombstoneChanged, never a |
| +// HeldError with an empty owner. |
| +func TestTombstoneWriteLostThenReadLostIsChanged(t *testing.T) { |
| + s := tombFixture(t) |
| + s.putLock(t, tombLoc, "box-a:1", 3, time.Now().Add(-time.Hour)) |
| + s.beforeWrite(func(string) { s.BareDelete("data", tombLoc+"/lock.json") }) |
| err := lease.Tombstone(context.Background(), "data", tombLoc, tombRegion, tombMove, time.Hour) |
| - wantHeld(t, err, "box-a:1") |
| - if _, after, _ := s.lock(t, tombLoc); string(after) != string(before) { |
| - t.Fatal("a lease inside the margin was replaced") |
| + var he *lease.HeldError |
| + if !errors.Is(err, lease.ErrTombstoneChanged) || errors.As(err, &he) { |
| + t.Fatalf("err = %v, want ErrTombstoneChanged and no HeldError", err) |
| + } |
| + if _, _, ok := s.lock(t, tombLoc); ok { |
| + t.Fatal("Tombstone wrote over the vanished lock") |
| + } |
| +} |
| + |
| +// The write loses its condition and the read-back shows this very owner |
| +// (another pass of the same move got there first): nothing to do. |
| +func TestTombstoneWriteLostToItsOwnOwnerIsNil(t *testing.T) { |
| + s := tombFixture(t) |
| + s.putLock(t, tombLoc, "box-a:1", 3, time.Now().Add(-time.Hour)) |
| + var theirs []byte |
| + s.beforeWrite(func(string) { |
| + s.putLock(t, tombLoc, tombMove, 4, time.Now().Add(2*time.Hour)) |
| + _, theirs, _ = s.lock(t, tombLoc) |
| + }) |
| + if err := lease.Tombstone(context.Background(), "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| + t.Fatalf("err = %v, want nil", err) |
| + } |
| + if _, now, _ := s.lock(t, tombLoc); string(now) != string(theirs) { |
| + t.Fatalf("the winner's lock was overwritten: %s -> %s", theirs, now) |
| } |
| } |
| |
| @@ -446,7 +505,7 @@ func TestLiftTombstone(t *testing.T) { |
| if err := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| t.Fatal(err) |
| } |
| - _, before, _ := s.lock(t, tombLoc) |
| + live, before, _ := s.lock(t, tombLoc) |
| |
| // Another move's tombstone is not ours to lift. |
| lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombOther)) |
| @@ -462,14 +521,24 @@ func TestLiftTombstone(t *testing.T) { |
| t.Fatalf("nil mine: lifted %v err %v", lifted, err) |
| } |
| |
| - // Ours lifts, and the host can be acquired again. |
| + // Ours lifts: by a conditional PUT, leaving the same owner and generation |
| + // with a long-past expiry, never by a DELETE. |
| + writes := s.writeCount() |
| lifted, err = lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, mine) |
| if err != nil || !lifted || called != tombMove { |
| t.Fatalf("lift: lifted %v err %v consulted %q", lifted, err, called) |
| } |
| - if _, _, ok := s.lock(t, tombLoc); ok { |
| - t.Fatal("lock.json is still there") |
| + if got := s.methods()[writes:]; len(got) != 1 || got[0] != http.MethodPut { |
| + t.Fatalf("lift wrote %v, want one PUT", got) |
| } |
| + l, _, ok := s.lock(t, tombLoc) |
| + if !ok || l.Owner != tombMove || l.Generation != live.Generation || !l.ExpiresAt.Before(time.Now().Add(-lease.RenewMargin)) { |
| + t.Fatalf("after the lift: %+v present %v, want %s at generation %d, long expired", l, ok, tombMove, live.Generation) |
| + } |
| + if held, owner, expires, err := lease.LockState(ctx, "data", tombLoc, tombRegion); err != nil || held || !lease.IsLiftedTombstone(owner, expires) { |
| + t.Fatalf("LockState after the lift: held %v owner %q expires %v err %v", held, owner, expires, err) |
| + } |
| + // The host can be acquired again, and so can a new tombstone. |
| held, err := lease.Acquire(ctx, "data", tombLoc, tombRegion) |
| if err != nil { |
| t.Fatalf("Acquire after the lift: %v", err) |
| @@ -477,17 +546,81 @@ func TestLiftTombstone(t *testing.T) { |
| if err := held.Release(ctx); err != nil { |
| t.Fatal(err) |
| } |
| +} |
| |
| - // Already lifted: (false, nil), however often it is asked. |
| +func TestLiftedTombstoneIsFreeToTheNextMove(t *testing.T) { |
| + s := tombFixture(t) |
| + ctx := context.Background() |
| + if err := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| + t.Fatal(err) |
| + } |
| + if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); err != nil || !lifted { |
| + t.Fatalf("lifted %v err %v", lifted, err) |
| + } |
| + if err := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombOther, time.Hour); err != nil { |
| + t.Fatalf("Tombstone over a lifted one: %v", err) |
| + } |
| + if l, _, _ := s.lock(t, tombLoc); l.Owner != tombOther { |
| + t.Fatalf("lock = %+v", l) |
| + } |
| +} |
| + |
| +// Already lifted, however it came about, is (false, nil) with nothing written. |
| +func TestLiftTombstoneAlreadyLifted(t *testing.T) { |
| + s := tombFixture(t) |
| + ctx := context.Background() |
| + if err := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| + t.Fatal(err) |
| + } |
| + if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); err != nil || !lifted { |
| + t.Fatalf("first lift: %v %v", lifted, err) |
| + } |
| + writes := s.writeCount() |
| for i := 0; i < 2; i++ { |
| - if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, mine); lifted || err != nil { |
| + if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); lifted || err != nil { |
| t.Fatalf("already lifted: lifted %v err %v", lifted, err) |
| } |
| } |
| + // An own tombstone that simply ran out is lifted too. |
| + s.putLock(t, tombLoc, tombMove, 4, time.Now().Add(-48*time.Hour)) |
| + writes = s.writeCount() |
| + if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); lifted || err != nil { |
| + t.Fatalf("expired own: lifted %v err %v", lifted, err) |
| + } |
| + if s.writeCount() != writes { |
| + t.Fatal("lifting an already-lifted tombstone wrote") |
| + } |
| + // And so is an absent one. |
| + s.BareDelete("data", tombLoc+"/lock.json") |
| + if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); lifted || err != nil { |
| + t.Fatalf("absent: lifted %v err %v", lifted, err) |
| + } |
| +} |
| + |
| +func TestIsLiftedTombstone(t *testing.T) { |
| + past, future := time.Now().Add(-time.Second), time.Now().Add(time.Hour) |
| + for name, tc := range map[string]struct { |
| + owner string |
| + expires time.Time |
| + want bool |
| + }{ |
| + "expired move": {tombMove, past, true}, |
| + "long expired move": {tombMove, time.Unix(0, 0), true}, |
| + "live move": {tombMove, future, false}, |
| + "expired ordinary lease": {"box-a:1", past, false}, |
| + "live ordinary lease": {"box-a:1", future, false}, |
| + "absent (zero owner/exp)": {"", time.Time{}, false}, |
| + "bare prefix": {"moved:", past, false}, |
| + "zero expiry": {tombMove, time.Time{}, false}, |
| + } { |
| + if got := lease.IsLiftedTombstone(tc.owner, tc.expires); got != tc.want { |
| + t.Errorf("%s: IsLiftedTombstone(%q, %v) = %v, want %v", name, tc.owner, tc.expires, got, tc.want) |
| + } |
| + } |
| } |
| |
| // Whatever mine says, only a moved:<id> lease is lifted: a live process's |
| -// lease is never this function's to delete. |
| +// lease is never this function's to touch. |
| func TestLiftTombstoneLeavesOrdinaryLeasesAlone(t *testing.T) { |
| s := tombFixture(t) |
| ctx := context.Background() |
| @@ -503,25 +636,11 @@ func TestLiftTombstoneLeavesOrdinaryLeasesAlone(t *testing.T) { |
| t.Fatalf("%s: the lease changed", name) |
| } |
| } |
| - if s.writeCount() != 0 { |
| + if s.writeCount() != 0 { // putLock writes the bucket directly; only requests count |
| t.Fatal("a refused lift wrote") |
| } |
| } |
| |
| -// Our own tombstone is lifted whether or not it has run out: the move that |
| -// wrote it is the one that says so. |
| -func TestLiftTombstoneLiftsAnExpiredOwn(t *testing.T) { |
| - s := tombFixture(t) |
| - s.putLock(t, tombLoc, tombMove, 4, time.Now().Add(-48*time.Hour)) |
| - lifted, err := lease.LiftTombstone(context.Background(), "data", tombLoc, tombRegion, isMine(tombMove)) |
| - if err != nil || !lifted { |
| - t.Fatalf("lifted %v err %v", lifted, err) |
| - } |
| - if _, _, ok := s.lock(t, tombLoc); ok { |
| - t.Fatal("lock.json is still there") |
| - } |
| -} |
| - |
| func TestLiftTombstoneFailsWhenTheLockChangesUnderIt(t *testing.T) { |
| ctx := context.Background() |
| t.Run("rewritten", func(t *testing.T) { |
| @@ -531,7 +650,7 @@ func TestLiftTombstoneFailsWhenTheLockChangesUnderIt(t *testing.T) { |
| } |
| var after []byte |
| s.beforeWrite(func(method string) { |
| - if method != http.MethodDelete { |
| + if method != http.MethodPut { |
| t.Errorf("LiftTombstone sent a %s", method) |
| } |
| // Still ours by owner, but a different lock: Reassert beat the lift. |
| @@ -543,12 +662,16 @@ func TestLiftTombstoneFailsWhenTheLockChangesUnderIt(t *testing.T) { |
| t.Fatalf("lifted %v err %v, want (false, ErrTombstoneChanged)", lifted, err) |
| } |
| if _, now, ok := s.lock(t, tombLoc); !ok || string(now) != string(after) { |
| - t.Fatalf("the lift deleted the rewrite: %s", now) |
| + t.Fatalf("the lift overwrote the rewrite: %s", now) |
| } |
| }) |
| t.Run("taken over by a holder", func(t *testing.T) { |
| s := tombFixture(t) |
| - s.putLock(t, tombLoc, tombMove, 4, time.Now().Add(-time.Hour)) |
| + // Live tombstone read; before the write a holder cannot legitimately |
| + // appear, but a rewrite by anything must still stand. |
| + if err := lease.Tombstone(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| + t.Fatal(err) |
| + } |
| var after []byte |
| s.beforeWrite(func(string) { |
| s.putLock(t, tombLoc, "box-b:2", 5, time.Now().Add(15*time.Minute)) |
| @@ -559,7 +682,7 @@ func TestLiftTombstoneFailsWhenTheLockChangesUnderIt(t *testing.T) { |
| t.Fatalf("lifted %v err %v, want (false, ErrTombstoneChanged)", lifted, err) |
| } |
| if _, now, _ := s.lock(t, tombLoc); string(now) != string(after) { |
| - t.Fatalf("the lift deleted a live holder's lock: %s", now) |
| + t.Fatalf("the lift overwrote a live holder's lock: %s", now) |
| } |
| }) |
| t.Run("removed", func(t *testing.T) { |
| @@ -571,6 +694,9 @@ func TestLiftTombstoneFailsWhenTheLockChangesUnderIt(t *testing.T) { |
| if lifted, err := lease.LiftTombstone(ctx, "data", tombLoc, tombRegion, isMine(tombMove)); lifted || err != nil { |
| t.Fatalf("lifted %v err %v, want (false, nil): already gone", lifted, err) |
| } |
| + if _, _, ok := s.lock(t, tombLoc); ok { |
| + t.Fatal("the lift re-created a removed lock") |
| + } |
| }) |
| } |
| |