eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– The live two-box mesh tests ("the kill-a-box demo", #550 Part 4 v2
4// Phase 3): TWO in-process boxes β€” two activator+router pairs on different
5// loopback ports, each spawning REAL instance processes β€” sharing one REAL S3
6// bucket for leases, replicas, and mesh heartbeats. They prove the mesh's
7// whole story:
8//
9// - a request landing on the NON-owner box is reverse-proxied to the HRW
10// owner, which wakes the instance and serves;
11// - killing the owner box moves its instance to the survivor with data
12// intact β€” in BOTH kill flavors:
13// drain (TestMeshLiveProxyAndDrainFailover): heartbeat deleted
14// first, checkpoint + lease release β€” the survivor takes
15// over on its next membership poll;
16// hard kill (TestMeshLiveHardKillFailover): the box just dies β€” its
17// heartbeat rots to staleness, its lease expires on TTL, and
18// the survivor's wake is gated by the lease until then.
19//
20// Gated on LCHAT_TEST_S3_BUCKET like the other live tests. Run with:
21//
22// eval "$(aws configure export-credentials --profile eleven-dev --format env)"
23// LCHAT_TEST_S3_BUCKET=<bucket> go test -run TestMeshLive -v .
24
25import (
26 "bytes"
27 "context"
28 "crypto/rand"
29 "encoding/hex"
30 "encoding/json"
31 "fmt"
32 "io"
33 "net/http"
34 "net/http/httptest"
35 "os"
36 "os/exec"
37 "path/filepath"
38 "strings"
39 "sync"
40 "testing"
41 "time"
42
43 "lchat/internal/activator"
44)
45
46// liveMeshBox is one box of the pair: its own activator (real s3Backend, real
47// child processes), registry, mesh, and front door.
48type liveMeshBox struct {
49 name string
50 self string // the front URL β€” this box's mesh identity
51 act *activatorSvc
52 mesh *meshSvc
53 front *httptest.Server
54 reg *Registry
55 route Route
56
57 shutdownOnce sync.Once
58}
59
60// shutdown drains the box exactly once β€” tests that dismantle a box by hand
61// (the hard kill) consume the Once so cleanup can't double-drain it.
62func (b *liveMeshBox) shutdown(ctx context.Context) {
63 b.shutdownOnce.Do(func() { b.act.shutdown(ctx) })
64}
65
66// setupMeshLive builds the shared world (S3 prefix, staged binary, env file)
67// and the two boxes. Both boxes register the same host, each pointing at its
68// OWN socket and DB path β€” a box must never see its peer's local files, so a
69// failover's restore provably comes from S3.
70func setupMeshLive(t *testing.T, host string) (boxA, boxB *liveMeshBox, adminToken string) {
71 t.Helper()
72 bucket := os.Getenv("LCHAT_TEST_S3_BUCKET")
73 if bucket == "" {
74 t.Skip("LCHAT_TEST_S3_BUCKET not set; skipping live two-box mesh test")
75 }
76 region := os.Getenv("LCHAT_TEST_S3_REGION")
77 if region == "" {
78 region = "eu-west-1"
79 }
80 rb := make([]byte, 4)
81 _, _ = rand.Read(rb)
82 prefix := fmt.Sprintf("mesh-live/%d-%s", time.Now().Unix(), hex.EncodeToString(rb))
83 t.Cleanup(func() { cleanupS3Prefix(t, bucket, prefix, region) })
84
85 // Sandbox the box-layout globals (shared by both in-process boxes: the
86 // staged binary, live symlink, and the instance env file are box-agnostic).
87 dataDir := t.TempDir()
88 sockDir := fmt.Sprintf("/tmp/lchat-mesh-%d", os.Getpid())
89 if err := os.MkdirAll(sockDir, 0o755); err != nil {
90 t.Fatal(err)
91 }
92 t.Cleanup(func() { os.RemoveAll(sockDir) })
93 optTmp := t.TempDir()
94 oldData, oldSock, oldOpt := instanceDataDir, instanceSocketDir, optDir
95 instanceDataDir, instanceSocketDir, optDir = dataDir, sockDir, optTmp
96 t.Cleanup(func() { instanceDataDir, instanceSocketDir, optDir = oldData, oldSock, oldOpt })
97
98 bin := filepath.Join(optTmp, "lchat-built")
99 build := exec.Command("go", "build", "-o", bin, ".")
100 build.Env = append(os.Environ(), "GOTOOLCHAIN=auto")
101 if out, err := build.CombinedOutput(); err != nil {
102 t.Fatalf("go build: %v\n%s", err, out)
103 }
104 if err := stageVersion(bin, "meshlive"); err != nil {
105 t.Fatalf("stage: %v", err)
106 }
107 if err := pointLive(host, "meshlive"); err != nil {
108 t.Fatalf("point live: %v", err)
109 }
110
111 adminToken = "meshlive-" + hex.EncodeToString(rb)
112 if err := writeEnvFile(filepath.Join(dataDir, host+".env"),
113 map[string]string{"LCHAT_ADMIN_TOKEN": adminToken}); err != nil {
114 t.Fatalf("env file: %v", err)
115 }
116
117 cfg := hibernateConfig{Bucket: bucket, Region: region, Prefix: prefix, Idle: 2 * time.Minute}
118 newBox := func(name string) *liveMeshBox {
119 box := &liveMeshBox{name: name}
120 var err error
121 box.reg, err = openRegistry(filepath.Join(t.TempDir(), "registry.db"))
122 if err != nil {
123 t.Fatalf("open registry: %v", err)
124 }
125 dbDir := filepath.Join(dataDir, name)
126 if err := os.MkdirAll(dbDir, 0o755); err != nil {
127 t.Fatal(err)
128 }
129 box.route = Route{Host: host, Kind: "instance",
130 Socket: filepath.Join(sockDir, name+".sock"),
131 DBPath: filepath.Join(dbDir, host+".db"),
132 Hibernate: true}
133 if err := box.reg.upsert(box.route); err != nil {
134 t.Fatalf("upsert: %v", err)
135 }
136 box.act = newActivator(cfg, &s3Backend{cfg: cfg}, box.reg)
137 rt, err := newRouter(box.reg, box.act)
138 if err != nil {
139 t.Fatalf("new router: %v", err)
140 }
141 box.front = httptest.NewServer(rt)
142 t.Cleanup(box.front.Close)
143 box.self = box.front.URL
144
145 hb, err := activator.NewHeartbeatStore(context.Background(), bucket, cfg.meshPrefix(), region)
146 if err != nil {
147 t.Fatalf("heartbeat store: %v", err)
148 }
149 box.mesh = newMesh(box.self, hb, nil)
150 box.mesh.interval = time.Second
151 box.mesh.staleAfter = 3 * time.Second
152 box.act.mesh = box.mesh
153 box.mesh.start()
154 box.act.start()
155 t.Cleanup(func() {
156 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
157 defer cancel()
158 box.shutdown(ctx)
159 })
160 return box
161 }
162 boxA, boxB = newBox("box-a"), newBox("box-b")
163
164 // Both boxes must see the full membership before the test routes anything.
165 waitForLive(t, "both boxes to see each other", 15*time.Second, func() bool {
166 return len(boxA.mesh.liveBoxes()) == 2 && len(boxB.mesh.liveBoxes()) == 2
167 })
168 return boxA, boxB, adminToken
169}
170
171// pickOwner orders the pair as (owner, survivor) for host by the same HRW
172// hash the boxes themselves use.
173func pickOwner(host string, a, b *liveMeshBox) (owner, survivor *liveMeshBox) {
174 if hrwOwner(host, []string{a.self, b.self}) == b.self {
175 return b, a
176 }
177 return a, b
178}
179
180func meshGet(t *testing.T, front, host, path string) (int, string) {
181 t.Helper()
182 req, _ := http.NewRequest("GET", front+path, nil)
183 req.Host = host
184 resp, err := http.DefaultClient.Do(req)
185 if err != nil {
186 t.Fatalf("GET %s: %v", path, err)
187 }
188 defer resp.Body.Close()
189 body, _ := io.ReadAll(resp.Body)
190 return resp.StatusCode, string(body)
191}
192
193func meshSetGroupName(t *testing.T, front, host, token, name string) {
194 t.Helper()
195 body, _ := json.Marshal(map[string]string{"name": name})
196 req, _ := http.NewRequest("POST", front+"/api/admin/group", bytes.NewReader(body))
197 req.Host = host
198 req.Header.Set("Authorization", "Bearer "+token)
199 req.Header.Set("Content-Type", "application/json")
200 resp, err := http.DefaultClient.Do(req)
201 if err != nil {
202 t.Fatalf("set group name: %v", err)
203 }
204 defer resp.Body.Close()
205 if resp.StatusCode != 200 {
206 b, _ := io.ReadAll(resp.Body)
207 t.Fatalf("set group name: %d %s", resp.StatusCode, b)
208 }
209}
210
211// pollUntilServed hammers the survivor's front door until the instance
212// answers 200, logging every distinct (status, body) transition β€” the test's
213// narrative of the failover: proxy-to-dead-peer 503s, then lease-held 503s,
214// then the wake. Returns the winning body.
215func pollUntilServed(t *testing.T, front, host string, deadline time.Duration) string {
216 t.Helper()
217 start := time.Now()
218 last := ""
219 for time.Now().Before(start.Add(deadline)) {
220 code, body := meshGet(t, front, host, "/api/group")
221 if code == 200 {
222 t.Logf(" +%s: 200 β€” served", time.Since(start).Truncate(time.Millisecond))
223 return body
224 }
225 if cur := fmt.Sprintf("%d %s", code, strings.TrimSpace(body)); cur != last {
226 t.Logf(" +%s: %s", time.Since(start).Truncate(time.Millisecond), cur)
227 last = cur
228 }
229 time.Sleep(time.Second)
230 }
231 t.Fatalf("instance never served on the survivor within %s", deadline)
232 return ""
233}
234
235// The drain variant: proxy-to-owner, then an orderly box exit (heartbeat
236// deleted FIRST, checkpoint, lease release) and the survivor taking over on
237// its next membership poll β€” the same code path a deploy or scale-down runs.
238func TestMeshLiveProxyAndDrainFailover(t *testing.T) {
239 const host = "mesh-drain.test"
240 boxA, boxB, adminToken := setupMeshLive(t, host)
241 owner, survivor := pickOwner(host, boxA, boxB)
242 t.Logf("HRW owner of %s is %s (%s); survivor is %s (%s)", host, owner.name, owner.self, survivor.name, survivor.self)
243
244 // --- Act 1: a request to the NON-owner proxies to the owner and serves. ---
245 start := time.Now()
246 code, _ := meshGet(t, survivor.front.URL, host, "/api/group")
247 if code != 200 {
248 t.Fatalf("request via non-owner: %d, want 200", code)
249 }
250 t.Logf("request to the non-owner proxied + woke the instance on %s in %s", owner.name, time.Since(start).Truncate(time.Millisecond))
251 if owner.act.entryCount() != 1 || !owner.act.entryAwake(host) {
252 t.Fatalf("instance is not awake on the owner (entries=%d)", owner.act.entryCount())
253 }
254 if survivor.act.entryCount() != 0 {
255 t.Fatal("the non-owner woke a local copy instead of proxying")
256 }
257
258 // Write real data through the proxied path.
259 groupName := "Mesh failover " + hex.EncodeToString([]byte(host))
260 meshSetGroupName(t, survivor.front.URL, host, adminToken, groupName)
261 t.Logf("wrote group name %q through the non-owner's front door", groupName)
262
263 // --- Act 2: drain the owner. Front door dark first (runRouter shuts the
264 // HTTP servers down before the activator β€” a draining box accepts no new
265 // work), then the drain proper: heartbeat delete, checkpoint, release. ---
266 start = time.Now()
267 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
268 defer cancel()
269 owner.front.Close()
270 owner.shutdown(ctx)
271 t.Logf("owner %s drained in %s (heartbeat deleted, instance checkpointed, lease released)", owner.name, time.Since(start).Truncate(time.Millisecond))
272
273 // The heartbeat really is gone from the bucket β€” only the survivor beats.
274 hbs, err := survivor.mesh.store.List(context.Background())
275 if err != nil {
276 t.Fatalf("list heartbeats: %v", err)
277 }
278 for _, hb := range hbs {
279 if hb.Peer == owner.self {
280 t.Fatal("drained owner's heartbeat is still in the bucket")
281 }
282 }
283
284 // --- Act 3: the survivor takes over β€” wake local, restore from S3. ---
285 body := pollUntilServed(t, survivor.front.URL, host, 60*time.Second)
286 if !strings.Contains(body, groupName) {
287 t.Fatalf("survivor lost the data: %s does not contain %q", body, groupName)
288 }
289 if !survivor.act.entryAwake(host) {
290 t.Fatal("instance is not awake on the survivor")
291 }
292 t.Logf("survivor %s restored the instance from S3 with data intact: %s", survivor.name, body)
293}
294
295// The hard-kill variant: the owner box dies with no goodbye β€” its heartbeat
296// is left to rot (stale in staleAfter) and its lease to expire (TTL). The
297// survivor's requests walk the whole failure narrative: proxy to the corpse
298// (503), local wake gated by the still-held lease (503 + Retry-After), then
299// the lease expires and the instance wakes on the survivor with every byte
300// that had replicated.
301func TestMeshLiveHardKillFailover(t *testing.T) {
302 const host = "mesh-kill.test"
303 boxA, boxB, adminToken := setupMeshLive(t, host)
304 owner, survivor := pickOwner(host, boxA, boxB)
305 t.Logf("HRW owner of %s is %s (%s); survivor is %s (%s)", host, owner.name, owner.self, survivor.name, survivor.self)
306
307 // --- Act 1: wake on the owner and write data. ---
308 if code, _ := meshGet(t, owner.front.URL, host, "/api/group"); code != 200 {
309 t.Fatalf("wake on owner: %d, want 200", code)
310 }
311 groupName := "Hard kill survivor " + hex.EncodeToString([]byte(host))
312 meshSetGroupName(t, owner.front.URL, host, adminToken, groupName)
313
314 // Make the write durable in S3 before the "crash" β€” a real hard kill
315 // loses only the last seconds of unshipped WAL (that's the design's RPO);
316 // the test pins the data so the assertion is about failover, not timing.
317 owner.act.mu.Lock()
318 entry := owner.act.entries[host]
319 owner.act.mu.Unlock()
320 if entry == nil {
321 t.Fatal("no live entry on the owner")
322 }
323 rep := entry.rep.(*activator.LeasedReplicator)
324 syncCtx, syncCancel := context.WithTimeout(context.Background(), 30*time.Second)
325 if err := rep.SyncAndWait(syncCtx); err != nil {
326 syncCancel()
327 t.Fatalf("sync before kill: %v", err)
328 }
329 syncCancel()
330 t.Logf("wrote group name %q on the owner and confirmed it replicated to S3", groupName)
331
332 // --- Act 2: hard-kill the owner box. Everything at once, nothing orderly:
333 // front door gone, sweeper gone, heartbeat loop gone (object NOT deleted),
334 // instance child SIGKILLed, lease renewal abandoned (NOT released). ---
335 owner.front.Close()
336 close(owner.act.sweepStop)
337 <-owner.act.sweepDone
338 owner.mesh.halt() // the heartbeat object stays β€” it must rot to staleness
339 owner.act.mu.Lock()
340 delete(owner.act.entries, host)
341 owner.act.mu.Unlock()
342 proc := entry.proc.(*childProc)
343 if err := proc.cmd.Process.Kill(); err != nil {
344 t.Fatalf("SIGKILL instance child: %v", err)
345 }
346 <-proc.done
347 abandonCtx, abandonCancel := context.WithTimeout(context.Background(), 30*time.Second)
348 rep.Abandon(abandonCtx)
349 abandonCancel()
350 owner.shutdownOnce.Do(func() {}) // consume: cleanup must not re-drain a corpse
351 t.Logf("owner %s hard-killed: heartbeat left in S3 (stale after %s), lease left to expire (TTL %s)",
352 owner.name, survivor.mesh.staleAfter, activator.LeaseTTL)
353
354 // --- Act 3: the survivor takes over once staleness + lease expiry pass. ---
355 start := time.Now()
356 body := pollUntilServed(t, survivor.front.URL, host, 120*time.Second)
357 if !strings.Contains(body, groupName) {
358 t.Fatalf("survivor lost the data: %s does not contain %q", body, groupName)
359 }
360 if !survivor.act.entryAwake(host) {
361 t.Fatal("instance is not awake on the survivor")
362 }
363 t.Logf("survivor %s took over %s after the owner's death with data intact: %s",
364 survivor.name, time.Since(start).Truncate(time.Millisecond), body)
365}
366