carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package lease
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/aws/aws-sdk-go-v2/aws"
14 "github.com/aws/aws-sdk-go-v2/credentials"
15 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
16 "github.com/benbjohnson/litestream"
17 lss3 "github.com/benbjohnson/litestream/s3"
18)
19
20// deleteStub answers the lease's DELETE with each status in turn and
21// records the If-Match header it was sent.
22type deleteStub struct {
23 mu sync.Mutex
24 statuses []int
25 ifMatch []string
26}
27
28func (s *deleteStub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
29 if r.Method != http.MethodDelete {
30 http.Error(w, "unexpected "+r.Method, http.StatusMethodNotAllowed)
31 return
32 }
33 s.mu.Lock()
34 s.ifMatch = append(s.ifMatch, r.Header.Get("If-Match"))
35 status := http.StatusNoContent
36 if len(s.statuses) > 0 {
37 status, s.statuses = s.statuses[0], s.statuses[1:]
38 }
39 s.mu.Unlock()
40 switch status {
41 case http.StatusNoContent:
42 w.WriteHeader(status)
43 case http.StatusPreconditionFailed:
44 w.Header().Set("Content-Type", "application/xml")
45 w.WriteHeader(status)
46 _, _ = w.Write([]byte("<Error><Code>PreconditionFailed</Code><Message>no</Message></Error>"))
47 default:
48 w.Header().Set("Content-Type", "application/xml")
49 w.WriteHeader(status)
50 _, _ = w.Write([]byte("<Error><Code>AccessDenied</Code><Message>no</Message></Error>"))
51 }
52}
53
54func (s *deleteStub) sent() []string {
55 s.mu.Lock()
56 defer s.mu.Unlock()
57 return append([]string(nil), s.ifMatch...)
58}
59
60// heldAgainst builds a Held whose lease record has ETag etag, with its
61// leaser pointed at srv. The TTL is long, so the renewer never renews
62// inside the test.
63func heldAgainst(t *testing.T, srv *httptest.Server, etag string) *Held {
64 t.Helper()
65 client := awss3.New(awss3.Options{
66 Region: "us-east-1",
67 BaseEndpoint: aws.String(srv.URL),
68 UsePathStyle: true,
69 Credentials: credentials.NewStaticCredentialsProvider("k", "s", ""),
70 RetryMaxAttempts: 1,
71 })
72 leaser := lss3.NewLeaser()
73 leaser.SetClient(client)
74 leaser.Bucket = "b"
75 leaser.Path = "p"
76 leaser.TTL = time.Hour
77 rec := &litestream.Lease{Generation: 1, ETag: etag, Owner: "test", ExpiresAt: time.Now().Add(time.Hour)}
78 return &Held{leaser: leaser, rn: startRenewer(leaser, rec, time.Now(), marginForTTL(time.Hour))}
79}
80
81// A failed release leaves a conditional obligation that RetryRelease
82// discharges with the same ETag (#292): Release spends the handle, so
83// without it the lease stays held for its whole TTL.
84func TestRetryReleaseDischargesAFailedRelease(t *testing.T) {
85 stub := &deleteStub{statuses: []int{http.StatusForbidden, http.StatusNoContent}}
86 srv := httptest.NewServer(stub)
87 defer srv.Close()
88 h := heldAgainst(t, srv, `"e1"`)
89 ctx := context.Background()
90
91 if err := h.Release(ctx); err == nil {
92 t.Fatal("Release succeeded against a refusing store")
93 }
94 if !h.ReleaseOwed() {
95 t.Fatal("a failed release left no obligation")
96 }
97 if err := h.RetryRelease(ctx); err != nil {
98 t.Fatalf("RetryRelease: %v", err)
99 }
100 if h.ReleaseOwed() {
101 t.Fatal("the obligation outlived a successful retry")
102 }
103 if err := h.RetryRelease(ctx); err != nil {
104 t.Fatalf("RetryRelease with nothing owed: %v", err)
105 }
106 got := stub.sent()
107 if len(got) != 2 || got[0] != `"e1"` || got[1] != `"e1"` {
108 t.Fatalf("If-Match sent = %q, want the lease's ETag on both deletes, and no third", got)
109 }
110}
111
112// A retry whose condition fails (the lease expired and someone else holds
113// it now) has nothing of ours left to release: the obligation is resolved,
114// and nothing else's lease is deleted.
115func TestRetryReleaseResolvesWhenTheLeaseIsNoLongerOurs(t *testing.T) {
116 stub := &deleteStub{statuses: []int{http.StatusForbidden, http.StatusPreconditionFailed}}
117 srv := httptest.NewServer(stub)
118 defer srv.Close()
119 h := heldAgainst(t, srv, `"e1"`)
120 ctx := context.Background()
121 _ = h.Release(ctx)
122 if err := h.RetryRelease(ctx); err != nil {
123 t.Fatalf("RetryRelease after a failed condition: %v", err)
124 }
125 if h.ReleaseOwed() {
126 t.Fatal("a lease that is no longer ours is still owed")
127 }
128}
129
130// A successful release owes nothing.
131func TestASuccessfulReleaseOwesNothing(t *testing.T) {
132 stub := &deleteStub{}
133 srv := httptest.NewServer(stub)
134 defer srv.Close()
135 h := heldAgainst(t, srv, `"e1"`)
136 if err := h.Release(context.Background()); err != nil {
137 t.Fatalf("Release: %v", err)
138 }
139 if h.ReleaseOwed() {
140 t.Fatal("a successful release left an obligation")
141 }
142}
143
144// Round 2 P2: under a persistent DELETE failure with acquisition still
145// allowed, obligations must not pile up forever. Each failed release stays
146// owed until its own record's expiry; past it, a retry that still fails
147// retires the obligation (ErrReleaseLapsed) rather than retrying forever.
148// Two acquisitions in a row, both failing to release, across that expiry.
149func TestAnOwedReleaseIsRetiredOnceItsRecordHasExpired(t *testing.T) {
150 stub := &deleteStub{statuses: []int{
151 http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden,
152 http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden,
153 }}
154 srv := httptest.NewServer(stub)
155 defer srv.Close()
156 ctx := context.Background()
157 expires := time.Now().Add(300 * time.Millisecond)
158 var held []*Held
159 for i := 0; i < 2; i++ {
160 h := heldExpiring(t, srv, fmt.Sprintf(`"e%d"`, i), expires)
161 if err := h.Release(ctx); err == nil {
162 t.Fatal("Release succeeded against a refusing store")
163 }
164 held = append(held, h)
165 }
166 for _, h := range held {
167 if err := h.RetryRelease(ctx); err == nil || errors.Is(err, ErrReleaseLapsed) {
168 t.Fatalf("a retry before the record's expiry = %v, want the store's refusal", err)
169 }
170 if !h.ReleaseOwed() {
171 t.Fatal("an unexpired record stopped being owed")
172 }
173 }
174 time.Sleep(time.Until(expires) + 50*time.Millisecond)
175 for _, h := range held {
176 if err := h.RetryRelease(ctx); !errors.Is(err, ErrReleaseLapsed) {
177 t.Fatalf("a failed retry past the record's expiry = %v, want ErrReleaseLapsed", err)
178 }
179 if h.ReleaseOwed() {
180 t.Fatal("an expired record is still owed")
181 }
182 }
183}
184
185// heldExpiring is heldAgainst with the record's own expiry set.
186func heldExpiring(t *testing.T, srv *httptest.Server, etag string, expires time.Time) *Held {
187 t.Helper()
188 h := heldAgainst(t, srv, etag)
189 h.rn.mu.Lock()
190 h.rn.lease.ExpiresAt = expires
191 h.rn.mu.Unlock()
192 return h
193}
194