carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/registry"
13)
14
15// gateFence is a WriteFence over a retired flag, serialized against
16// retire() the way the activator's is against its switch. inside is true
17// while a write runs in it.
18type gateFence struct {
19 mu sync.RWMutex
20 retired bool
21 inside atomic.Bool
22}
23
24func (f *gateFence) fence(write func()) bool {
25 f.mu.RLock()
26 defer f.mu.RUnlock()
27 if f.retired {
28 return false
29 }
30 f.inside.Store(true)
31 defer f.inside.Store(false)
32 write()
33 return true
34}
35
36func (f *gateFence) retire() {
37 f.mu.Lock()
38 f.retired = true
39 f.mu.Unlock()
40}
41
42// A retiring generation's writes are refused with 409 once the switch has
43// happened (zero-downtime spec §7.10). The successor's writes apply. Reads
44// stay open, and the bearer is still checked first.
45func TestMountGatedRefusesARetiredGenerationsWrites(t *testing.T) {
46 reg, _ := controlFixture(t)
47 f := &gateFence{}
48 mux := http.NewServeMux()
49 MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil },
50 func() time.Time { return at("2026-08-23T13:07:00Z") }, f.fence)
51 body := `{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}`
52 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body); rec.Code != http.StatusOK {
53 t.Fatalf("current generation's write = %d %s, want 200", rec.Code, rec.Body)
54 }
55 f.retire()
56 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body); rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), RetiredWriteMessage) {
57 t.Fatalf("retired PUT = %d %q, want 409 with RetiredWriteMessage", rec.Code, rec.Body)
58 }
59 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusConflict {
60 t.Fatalf("retired DELETE = %d, want 409", rec.Code)
61 }
62 if row, err := reg.GetTimer("a.bab.oncarlos.com", "remind"); err != nil || row == nil {
63 t.Fatalf("a refused DELETE removed the timer: %v", err)
64 }
65 if rec := do(mux, "GET", "/schedule", "Bearer tok", ""); rec.Code != http.StatusOK {
66 t.Fatalf("retired GET = %d, want 200", rec.Code)
67 }
68 if rec := do(mux, "PUT", "/schedule/remind", "Bearer wrong", body); rec.Code != http.StatusUnauthorized {
69 t.Fatalf("a retired generation's bad bearer = %d, want 401", rec.Code)
70 }
71 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer wrong", ""); rec.Code != http.StatusUnauthorized {
72 t.Fatalf("a retired generation's bad bearer on DELETE = %d, want 401", rec.Code)
73 }
74}
75
76// A refused PUT changes nothing: the row the successor wrote survives a
77// retiring generation's attempt to overwrite it.
78func TestMountGatedRefusedPutLeavesTheRowAlone(t *testing.T) {
79 reg, _ := controlFixture(t)
80 f := &gateFence{}
81 mux := http.NewServeMux()
82 MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil },
83 func() time.Time { return at("2026-08-23T13:07:00Z") }, f.fence)
84 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/new"}`); rec.Code != http.StatusOK {
85 t.Fatalf("PUT = %d %s", rec.Code, rec.Body)
86 }
87 f.retire()
88 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/jobs/old"}`); rec.Code != http.StatusConflict {
89 t.Fatalf("retired PUT = %d, want 409", rec.Code)
90 }
91 row, err := reg.GetTimer("a.bab.oncarlos.com", "remind")
92 if err != nil || row.Path != "/jobs/new" {
93 t.Fatalf("a refused PUT changed the row: %+v, %v", row, err)
94 }
95}
96
97// A nil gate is Mount exactly: both writing verbs apply.
98func TestMountGatedNilIsMount(t *testing.T) {
99 reg, _ := controlFixture(t)
100 mux := http.NewServeMux()
101 MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil },
102 func() time.Time { return at("2026-08-23T13:07:00Z") }, nil)
103 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusOK {
104 t.Fatalf("PUT = %d", rec.Code)
105 }
106 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent {
107 t.Fatalf("DELETE = %d, want 204", rec.Code)
108 }
109 if _, err := reg.GetTimer("a.bab.oncarlos.com", "remind"); err == nil {
110 t.Fatal("a nil gate's DELETE left the timer in place")
111 }
112}
113
114// stallBody is a request body whose first Read signals entered and then
115// blocks until release closes: a client stalled mid-body.
116type stallBody struct {
117 r *strings.Reader
118 entered chan struct{}
119 release chan struct{}
120 first atomic.Bool
121}
122
123func (b *stallBody) Read(p []byte) (int, error) {
124 if b.first.CompareAndSwap(false, true) {
125 close(b.entered)
126 <-b.release
127 }
128 return b.r.Read(p)
129}
130func (b *stallBody) Close() error { return nil }
131
132// Final review F8: a retiring generation's PUT that began before the
133// switch and stalled in its body cannot overwrite the successor's write.
134// The retirement decision is made inside the fence, after the body, and
135// the switch is serialized against it.
136func TestRetiredPutStalledInItsBodyCannotOverwriteTheSuccessor(t *testing.T) {
137 reg, _ := controlFixture(t)
138 now := func() time.Time { return at("2026-08-23T13:07:00Z") }
139 tok := func() (string, error) { return "tok", nil }
140 f := &gateFence{}
141 oldMux, newMux := http.NewServeMux(), http.NewServeMux()
142 MountGated(oldMux, "a.bab.oncarlos.com", reg, tok, now, f.fence)
143 MountGated(newMux, "a.bab.oncarlos.com", reg, tok, now, (&gateFence{}).fence)
144 body := &stallBody{r: strings.NewReader(`{"at":"2026-09-02T08:00:00Z","path":"/jobs/old"}`), entered: make(chan struct{}), release: make(chan struct{})}
145 req := httptest.NewRequest("PUT", "/schedule/remind", nil)
146 req.Body = body
147 req.Header.Set("Authorization", "Bearer tok")
148 rec := httptest.NewRecorder()
149 done := make(chan struct{})
150 go func() { oldMux.ServeHTTP(rec, req); close(done) }()
151 <-body.entered
152 f.retire() // the switch: it does not wait on a request still reading its body
153 if r := do(newMux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/new"}`); r.Code != http.StatusOK {
154 t.Fatalf("successor PUT = %d %s", r.Code, r.Body)
155 }
156 close(body.release)
157 <-done
158 if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), RetiredWriteMessage) {
159 t.Errorf("the retired generation's stalled PUT = %d %q, want 409", rec.Code, rec.Body)
160 }
161 if row, err := reg.GetTimer("a.bab.oncarlos.com", "remind"); err != nil || row.Path != "/jobs/new" {
162 t.Fatalf("the successor's timer was overwritten: %+v, %v", row, err)
163 }
164}
165
166// fenceCheckStore fails the test when a writing verb touches the store
167// outside its generation's fence.
168type fenceCheckStore struct {
169 TimerStore
170 t *testing.T
171 f *gateFence
172}
173
174func (s fenceCheckStore) in(op string) {
175 if !s.f.inside.Load() {
176 s.t.Errorf("%s ran outside the write fence", op)
177 }
178}
179func (s fenceCheckStore) UpsertTimer(t registry.Timer) error {
180 s.in("UpsertTimer")
181 return s.TimerStore.UpsertTimer(t)
182}
183func (s fenceCheckStore) DeleteTimer(h, n string) error {
184 s.in("DeleteTimer")
185 return s.TimerStore.DeleteTimer(h, n)
186}
187func (s fenceCheckStore) GetTimer(h, n string) (*registry.Timer, error) {
188 s.in("GetTimer")
189 return s.TimerStore.GetTimer(h, n)
190}
191func (s fenceCheckStore) CountTimers(h, k string) (int, error) {
192 s.in("CountTimers")
193 return s.TimerStore.CountTimers(h, k)
194}
195
196// Final review F8: PUT's and DELETE's store checks and mutations all run
197// inside the fence, so the final retirement decision and the write are one
198// step against the switch (DELETE's check/use race included).
199func TestMountGatedWritesOnlyInsideTheFence(t *testing.T) {
200 reg, _ := controlFixture(t)
201 f := &gateFence{}
202 mux := http.NewServeMux()
203 MountGated(mux, "a.bab.oncarlos.com", fenceCheckStore{TimerStore: reg, t: t, f: f}, func() (string, error) { return "tok", nil },
204 func() time.Time { return at("2026-08-23T13:07:00Z") }, f.fence)
205 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusOK {
206 t.Fatalf("PUT = %d %s", rec.Code, rec.Body)
207 }
208 if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/r2"}`); rec.Code != http.StatusOK {
209 t.Fatalf("second PUT = %d %s", rec.Code, rec.Body)
210 }
211 if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent {
212 t.Fatalf("DELETE = %d %s", rec.Code, rec.Body)
213 }
214}
215