server: the activator mesh — multi-box placement for hibernating instances (#550 Part 4 v2, Phase 3) (#678)
Multiple router boxes now cooperate so a hibernating instance wakes on exactly one box, a request landing on the wrong box reverse-proxies to the owner, and a dead box's instances wake elsewhere. Four mechanisms: - Membership: S3 heartbeat objects (internal/activator/heartbeat.go) under <prefix>/mesh/members — the bucket is already the shared substrate, so no new ports/protocols between boxes. The key IS the record (base64url of the peer URL; one LIST, no GETs), LastModified is the liveness clock, stale after 3 missed beats. Identity = LCHAT_MESH_SELF (a peer-reachable base URL); unset = single box, zero mesh codepaths (tripwire-tested). - Rendezvous (HRW) hashing: owner(host) is a pure function of the live member set; a membership change moves only ~1/N of hosts. - The lease stays the truth; hashing is only a hint. A wrong-believer loses the CAS race loudly and 503s — membership disagreement can't split-brain. - Proxy-to-owner: Host preserved (the peer routes by Host); https peers get SNI + cert verification against the *instance* hostname with TCP pinned to the peer's address (never InsecureSkipVerify); a forwarded marker caps proxying at one hop. Dead/stale owner falls through to a local wake. Drain (activatorSvc.shutdown) deletes the heartbeat FIRST, then checkpoints and releases as before — deploys, box failure, and scale-down are one code path. Due-time sweeps only fire on the HRW owner. Also hardens the final checkpoint: LeasedReplicator.Close now forces a SyncAndWait so a drain right after a wake can't ship an empty replica (litestream only final-syncs an initialized DB), and gains Abandon() so tests can simulate hard box death. Tested: HRW determinism/redistribution, staleness, routing decision, proxy rewrite semantics, drain ordering (order-pinned), due-sweep gating, mesh-off tripwire — plus two live two-box kill-a-box tests against real S3 (gated on LCHAT_TEST_S3_BUCKET): drain failover (survivor takes over in ~2s) and hard kill (heartbeat rots, lease expires, survivor restores with data intact in ~30s).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+1627
−3
activator.go+67 −0internal/activator/heartbeat.go+137 −0internal/activator/heartbeat_test.go+23 −0internal/activator/replica.go+22 −0mesh.go+356 −0mesh_live_test.go+365 −0mesh_test.go+633 −0router.go+24 −3
diff --git a/activator.go b/activator.go| index 2072cdf..7c839bd 100644 |
| --- a/activator.go |
| +++ b/activator.go |
| @@ -109,6 +109,14 @@ func (c *hibernateConfig) hibernatePrefix(host string) string { |
| return path.Join(c.Prefix, host) |
| } |
| +// meshPrefix is where the mesh's membership heartbeats live, beside the |
| +// per-instance prefixes — the bucket is the mesh's only shared substrate. |
| +// It can't collide with hibernatePrefix: instance prefixes are public FQDNs |
| +// (they contain a dot), and the bare label "mesh" never is one. |
| +func (c *hibernateConfig) meshPrefix() string { |
| + return path.Join(c.Prefix, "mesh/members") |
| +} |
| + |
| // --- backend seam ------------------------------------------------------------ |
| // |
| // The state machine's side effects (S3 lease, restore, process spawn) sit |
| @@ -261,6 +269,13 @@ type activatorSvc struct { |
| due actDueStore |
| now func() time.Time // a seam for the unit tests' clock |
| + // mesh, when non-nil, is the multi-box placement layer (mesh.go): requests |
| + // for hosts whose HRW owner is another live box are proxied there, and the |
| + // due-time sweeper only wakes hosts this box owns. Nil (LCHAT_MESH_SELF |
| + // unset) is a single box — hashing would pick self everywhere, so no mesh |
| + // codepath is touched at all. |
| + mesh *meshSvc |
| + |
| mu sync.Mutex |
| entries map[string]*actEntry |
| @@ -421,6 +436,17 @@ func (a *activatorSvc) beginSidecarRequest(ctx context.Context, rt Route) error |
| } |
| } |
| +// hasLiveEntry reports whether host has an entry in any state — the mesh's |
| +// "already living here" check (waking and stopping both count: a waking host |
| +// is committed to this box, and a stopping one still holds the lease, so |
| +// proxying it away would only trade a short wait for a loud lease race). |
| +func (a *activatorSvc) hasLiveEntry(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + _, ok := a.entries[host] |
| + return ok |
| +} |
| + |
| func (a *activatorSvc) releaseFunc(e *actEntry) func() { |
| var once sync.Once |
| return func() { |
| @@ -721,6 +747,19 @@ func (a *activatorSvc) sweepOnce() { |
| if _, live := a.entries[rt.Host]; live { |
| continue |
| } |
| + // Mesh: only the HRW owner due-wakes a host — otherwise every box |
| + // sweeping a due entry would race a wake per tick (the lease would let |
| + // exactly one win, but loudly, forever). If the owner box is dead its |
| + // heartbeat goes stale and the next tick's membership makes someone |
| + // else the owner — eventual, bounded by staleAfter, and a scheduled |
| + // delivery is late by at most that. (Today each box records next_due |
| + // in its own registry, so the recorder and the owner are usually the |
| + // same box; a host whose ownership moved while asleep waits for the |
| + // registry-sync work — or a member request — to fire. Known gap, |
| + // deliberately deferred with the shared-registry question.) |
| + if a.mesh != nil && !a.mesh.owns(rt.Host) { |
| + continue |
| + } |
| log.Printf("activator: %s has a scheduled delivery due — waking it", rt.Host) |
| e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})} |
| a.entries[rt.Host] = e |
| @@ -781,6 +820,14 @@ func (a *activatorSvc) retireExcept(keep map[string]bool) { |
| // the leases until TTL expiry and leave the sockets bound against the next |
| // incarnation's spawns). Blocks until all stops complete or ctx expires. |
| func (a *activatorSvc) shutdown(ctx context.Context) { |
| + // Drain step 1, order-pinned: leave the mesh membership FIRST — the |
| + // heartbeat is deleted before any checkpoint or lease release below, so |
| + // peers stop proxying here immediately and their wakes succeed the moment |
| + // each lease releases. Deploys, box failure drills, and scale-down all |
| + // ride this same path. |
| + if a.mesh != nil { |
| + a.mesh.stop(ctx) |
| + } |
| if a.sweepStop != nil { |
| close(a.sweepStop) |
| <-a.sweepDone |
| @@ -839,6 +886,26 @@ func (a *activatorSvc) serveHTTP(w http.ResponseWriter, r *http.Request, rt Rout |
| return |
| } |
| + // Mesh placement (mesh.go): a member request for a host with no live local |
| + // entry and an HRW owner on another live box is proxied there instead of |
| + // woken here. A forwarded request is never re-proxied (one hop max — see |
| + // meshForwardedHeader). A host with ANY live local entry stays local: an |
| + // awake instance finishes its natural life where it is, and placement |
| + // converges at the next sleep boundary — its state is already in S3, so |
| + // "migrating" is just waking on the new owner. The check-then-proxy is |
| + // deliberately not atomic with the entry map: a race sends one request the |
| + // long way, or wakes a not-the-hint box, and the lease arbitrates exactly |
| + // as it would for real membership disagreement. (Sidecar-marked traffic |
| + // above never routes through the mesh — a co-spawned sidecar's instance is |
| + // by construction local, and a stray marked request for a host living |
| + // elsewhere is exactly the reconnect-racing-a-stop case the 503 exists for.) |
| + if a.mesh != nil && r.Header.Get(meshForwardedHeader) == "" && !a.hasLiveEntry(rt.Host) { |
| + if peer, remote := a.mesh.remoteOwner(rt.Host); remote { |
| + a.mesh.proxyTo(w, r, peer) |
| + return |
| + } |
| + } |
| + |
| release, err := a.beginRequest(r.Context(), rt) |
| if err != nil { |
| var held *errLeaseHeld |
diff --git a/internal/activator/heartbeat.go b/internal/activator/heartbeat.go| new file mode 100644 |
| index 0000000..e433d9c |
| --- /dev/null |
| +++ b/internal/activator/heartbeat.go |
| @@ -0,0 +1,137 @@ |
| +// 🤖 Mesh membership heartbeats (issue #550 Part 4 v2, Phase 3 — "the |
| +// activator mesh"). Boxes learn who's alive from the bucket itself: each live |
| +// box PUTs one object under a shared members prefix on an interval, LISTs the |
| +// prefix to see its peers, and DELETEs its object on drain. S3 was chosen over |
| +// gossip (memberlist) deliberately: the bucket is already the mesh's shared |
| +// substrate — the leases and replicas live there — so membership needs no new |
| +// ports or protocols between boxes, which may not even share a network (the |
| +// current prod box plus a future AWS pool). Membership changes are rare |
| +// events; a ~15s poll is plenty. |
| +// |
| +// The KEY is the record: an object key is base64url(peer URL), so one LIST — |
| +// no per-member GETs — yields the whole membership, and LastModified (the |
| +// bucket's clock, not the writer's) is the liveness timestamp, so writer |
| +// clock skew never enters the staleness judgement. The body duplicates the |
| +// URL as JSON purely for operators poking at the bucket. On a versioned |
| +// bucket the beats churn noncurrent versions; the standard keep-N lifecycle |
| +// prunes them. |
| + |
| +package activator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "fmt" |
| + "path" |
| + "strings" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| +) |
| + |
| +// Heartbeat is one box's liveness record in the mesh membership prefix. |
| +type Heartbeat struct { |
| + Peer string // the box's peer base URL — its mesh identity |
| + LastSeen time.Time // the object's S3 LastModified — the bucket's clock |
| +} |
| + |
| +// HeartbeatStore reads and writes box heartbeats under s3://bucket/prefix/. |
| +type HeartbeatStore struct { |
| + client *awss3.Client |
| + bucket string |
| + prefix string |
| +} |
| + |
| +// NewHeartbeatStore builds a store on the default AWS credential chain, like |
| +// AcquireLease. Region may be empty; the chain resolves it. |
| +func NewHeartbeatStore(ctx context.Context, bucket, prefix, region string) (*HeartbeatStore, error) { |
| + var opts []func(*awsconfig.LoadOptions) error |
| + if region != "" { |
| + opts = append(opts, awsconfig.WithRegion(region)) |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + if err != nil { |
| + return nil, fmt.Errorf("load aws config: %w", err) |
| + } |
| + return &HeartbeatStore{ |
| + client: awss3.NewFromConfig(cfg), |
| + bucket: bucket, |
| + prefix: strings.Trim(prefix, "/"), |
| + }, nil |
| +} |
| + |
| +// encodeHeartbeatKey / decodeHeartbeatKey turn a peer URL into an S3-safe key |
| +// leaf and back (URLs carry '/' and ':', which would fake folder structure). |
| +func encodeHeartbeatKey(peer string) string { |
| + return base64.RawURLEncoding.EncodeToString([]byte(peer)) |
| +} |
| + |
| +func decodeHeartbeatKey(leaf string) (string, error) { |
| + b, err := base64.RawURLEncoding.DecodeString(leaf) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
| + |
| +func (s *HeartbeatStore) key(peer string) string { |
| + return path.Join(s.prefix, encodeHeartbeatKey(peer)) |
| +} |
| + |
| +// Put writes (or refreshes) this box's heartbeat. The write itself is the |
| +// timestamp — LastModified advances on every beat. |
| +func (s *HeartbeatStore) Put(ctx context.Context, peer string) error { |
| + body, err := json.Marshal(map[string]string{"peer": peer}) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(s.bucket), |
| + Key: aws.String(s.key(peer)), |
| + Body: bytes.NewReader(body), |
| + }) |
| + return err |
| +} |
| + |
| +// List returns every heartbeat under the prefix. Objects whose key leaf isn't |
| +// a decodable peer record are skipped — a foreign object in the prefix must |
| +// never poison membership. |
| +func (s *HeartbeatStore) List(ctx context.Context) ([]Heartbeat, error) { |
| + var out []Heartbeat |
| + p := awss3.NewListObjectsV2Paginator(s.client, &awss3.ListObjectsV2Input{ |
| + Bucket: aws.String(s.bucket), |
| + Prefix: aws.String(s.prefix + "/"), |
| + }) |
| + for p.HasMorePages() { |
| + page, err := p.NextPage(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, obj := range page.Contents { |
| + peer, err := decodeHeartbeatKey(path.Base(aws.ToString(obj.Key))) |
| + if err != nil { |
| + continue |
| + } |
| + var last time.Time |
| + if obj.LastModified != nil { |
| + last = *obj.LastModified |
| + } |
| + out = append(out, Heartbeat{Peer: peer, LastSeen: last}) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// Delete removes this box's heartbeat — the FIRST step of a drain, before any |
| +// checkpoint or lease release, so peers take over immediately. |
| +func (s *HeartbeatStore) Delete(ctx context.Context, peer string) error { |
| + _, err := s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(s.bucket), |
| + Key: aws.String(s.key(peer)), |
| + }) |
| + return err |
| +} |
diff --git a/internal/activator/heartbeat_test.go b/internal/activator/heartbeat_test.go| new file mode 100644 |
| index 0000000..25ff927 |
| --- /dev/null |
| +++ b/internal/activator/heartbeat_test.go |
| @@ -0,0 +1,23 @@ |
| +package activator |
| + |
| +import "testing" |
| + |
| +// The key IS the membership record (one LIST, no GETs), so the encoding must |
| +// round-trip any peer URL and reject foreign objects. |
| +func TestHeartbeatKeyRoundTrip(t *testing.T) { |
| + for _, peer := range []string{ |
| + "http://10.0.0.5:8080", |
| + "https://box-a.internal", |
| + "https://box-b.mesh.example.com:8443", |
| + } { |
| + leaf := encodeHeartbeatKey(peer) |
| + if got, err := decodeHeartbeatKey(leaf); err != nil || got != peer { |
| + t.Fatalf("round-trip %q: got %q, %v", peer, got, err) |
| + } |
| + } |
| + // A foreign object's key leaf must fail decode (List skips it) rather than |
| + // turn into a phantom member. |
| + if _, err := decodeHeartbeatKey("not/base64!"); err == nil { |
| + t.Fatal("garbage key leaf decoded without error") |
| + } |
| +} |
diff --git a/internal/activator/replica.go b/internal/activator/replica.go| index aba972f..177fbe0 100644 |
| --- a/internal/activator/replica.go |
| +++ b/internal/activator/replica.go |
| @@ -212,9 +212,31 @@ func (r *LeasedReplicator) SyncAndWait(ctx context.Context) error { |
| // Err reports whether lease renewal has failed (leadership lost). |
| func (r *LeasedReplicator) Err() error { return r.rn.Err() } |
| +// Abandon stops lease renewal and replication WITHOUT the final sync or the |
| +// lease release — the wreckage a crashed box leaves behind: lock.json simply |
| +// expires on its TTL, and the replica holds whatever was last synced. It |
| +// exists so tests can simulate hard box death in-process; production code has |
| +// no reason to call it (a real crash needs no help). |
| +func (r *LeasedReplicator) Abandon(ctx context.Context) { |
| + r.rn.stop() // deliberately no ReleaseLease — the TTL is the point |
| + // litestream's only teardown is Close, which includes a sync a real crash |
| + // would skip; it ships nothing as long as the caller killed the writer |
| + // first, and reclaims the replication goroutines either way. |
| + _ = r.store.Close(ctx) |
| +} |
| + |
| // Close stops replication (final sync included via litestream's DB.Close) |
| // and releases the lease so the next claimant can acquire immediately. |
| func (r *LeasedReplicator) Close(ctx context.Context) error { |
| + // Force one full sync before teardown. litestream's DB.Close only |
| + // final-syncs an *initialized* DB, and initialization happens on the |
| + // monitor's first pass — so a checkpoint within ~a second of Replicate |
| + // could silently ship nothing (a drain right after a wake, caught by the |
| + // mesh live test: the replica held no restorable snapshot). SyncAndWait |
| + // forces init + a replica sync; its error is deliberately dropped — with |
| + // the DB now initialized, store.Close's own final sync is the one that |
| + // counts, and its error is returned. |
| + _ = r.db.SyncAndWait(ctx) |
| err := r.store.Close(ctx) |
| lease := r.rn.stop() |
diff --git a/mesh.go b/mesh.go| new file mode 100644 |
| index 0000000..1e686a6 |
| --- /dev/null |
| +++ b/mesh.go |
| @@ -0,0 +1,356 @@ |
| +package main |
| + |
| +// The activator mesh (issue #550 Part 4 v2, Phase 3): multiple router boxes |
| +// cooperate so a hibernating instance wakes on exactly one box, a request |
| +// landing on the wrong box is reverse-proxied to the owner, and a dead box's |
| +// instances wake elsewhere. Four mechanisms, deliberately this simple: |
| +// |
| +// 1. Membership — boxes learn who's alive from S3 heartbeat objects |
| +// (internal/activator/heartbeat.go), not gossip: the bucket is already the |
| +// shared substrate, so no new ports or protocols between boxes (which may |
| +// not even share a network). Each box PUTs mesh/members/<box> every |
| +// meshHeartbeatInterval and LISTs the prefix; a record older than |
| +// meshStaleAfter is a dead box. A box's identity is LCHAT_MESH_SELF — a |
| +// base URL its peers can reach it at; the env's presence turns the mesh |
| +// on, its absence keeps today's single-box behavior byte-identical. |
| +// 2. Rendezvous (HRW) hashing — owner(host) is a pure function of the live |
| +// member set (hrwOwner): no routing table, no directory service, and a |
| +// membership change moves only ~1/N of hosts (only onto a joiner, only |
| +// off a leaver). |
| +// 3. The lease stays the truth; hashing is only a hint. The owner claims the |
| +// per-host S3 lease exactly as on one box; a box that wrongly believes |
| +// it's the owner loses the CAS race loudly (503 + Retry-After) and the |
| +// client's retry lands right. Transient membership disagreement therefore |
| +// cannot split-brain — single-writer was never the mesh's job. |
| +// 4. Proxy-to-owner — a request for a sleeping host whose HRW owner is |
| +// another live box is reverse-proxied to that box's peer URL, instance |
| +// hostname preserved (the peer routes by Host like any front door). A |
| +// dead/stale owner drops out of the member set, so the hash reassigns and |
| +// the request falls through to a local wake — failover IS the routine |
| +// wake path. |
| +// |
| +// Drain (activatorSvc.shutdown) deletes the heartbeat FIRST, then checkpoints |
| +// and releases as today — peers take over immediately, and deploys, box |
| +// failure, and scale-down are the same code path. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "hash/fnv" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "net/http/httputil" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "lchat/internal/activator" |
| +) |
| + |
| +// meshForwardedHeader marks a request one box already proxied to another: |
| +// "serve where you stand, never re-proxy". One hop max — if two boxes |
| +// transiently disagree about ownership, the worst case is a wrong-box wake |
| +// the lease arbitrates, never a proxy loop. An outsider spoofing it merely |
| +// pins its own request to the box it hit, which is exactly what every request |
| +// could do before the mesh existed; the lease still guarantees one writer. |
| +// Stripped before any backend sees it (unixProxy), like the sidecar marker. |
| +const meshForwardedHeader = "X-Lchat-Mesh-Forwarded" |
| + |
| +const ( |
| + // meshHeartbeatInterval paces the PUT + LIST beat. Membership changes are |
| + // rare events; this is liveness, not coordination. |
| + meshHeartbeatInterval = 15 * time.Second |
| + // meshStaleAfter is how old a heartbeat may be before its box is presumed |
| + // dead — three missed beats. It's judged against the reader's clock but |
| + // the timestamp is S3's LastModified (the bucket's clock), so only |
| + // reader-vs-S3 skew matters, tiny against 45s. Together with the ~30s |
| + // lease TTL this bounds failover: a hard-killed box's hosts wake elsewhere |
| + // once its heartbeat is stale AND its leases expire. |
| + meshStaleAfter = 3 * meshHeartbeatInterval |
| +) |
| + |
| +// meshStore is the S3 heartbeat surface (activator.HeartbeatStore in |
| +// production), an interface so the mesh's unit tests share an in-memory |
| +// "bucket" between boxes. |
| +type meshStore interface { |
| + Put(ctx context.Context, peer string) error |
| + List(ctx context.Context) ([]activator.Heartbeat, error) |
| + Delete(ctx context.Context, peer string) error |
| +} |
| + |
| +type meshSvc struct { |
| + self string // this box's peer base URL — its mesh identity |
| + store meshStore |
| + interval time.Duration |
| + staleAfter time.Duration |
| + now func() time.Time // a seam for the unit tests' clock |
| + |
| + mu sync.Mutex |
| + members []activator.Heartbeat // last LIST; staleness applied at read time |
| + proxies map[string]*httputil.ReverseProxy |
| + started bool |
| + |
| + stopCh chan struct{} |
| + done chan struct{} |
| +} |
| + |
| +func newMesh(self string, store meshStore) *meshSvc { |
| + return &meshSvc{ |
| + self: self, |
| + store: store, |
| + interval: meshHeartbeatInterval, |
| + staleAfter: meshStaleAfter, |
| + now: time.Now, |
| + proxies: make(map[string]*httputil.ReverseProxy), |
| + stopCh: make(chan struct{}), |
| + done: make(chan struct{}), |
| + } |
| +} |
| + |
| +// meshFromEnv reads the box's mesh identity. LCHAT_MESH_SELF is a base URL |
| +// peer boxes can reach this box at, scheme included — http on a private |
| +// network, or https where the box presents a verifiable cert (see |
| +// newPeerProxy). Presence turns the mesh on; absent = single box, zero mesh |
| +// codepaths. The mesh rides on the hibernation bucket, so it requires |
| +// LCHAT_HIBERNATE_BUCKET. |
| +func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| + self := strings.TrimSpace(os.Getenv("LCHAT_MESH_SELF")) |
| + if self == "" { |
| + return nil, nil |
| + } |
| + if hc == nil { |
| + return nil, errors.New("LCHAT_MESH_SELF set but LCHAT_HIBERNATE_BUCKET missing — the mesh rides on the hibernation bucket and only places hibernating instances") |
| + } |
| + self = strings.TrimRight(self, "/") |
| + u, err := url.Parse(self) |
| + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "" { |
| + return nil, fmt.Errorf("LCHAT_MESH_SELF %q: want a base URL like https://box-a.internal or http://10.0.0.5:8080", self) |
| + } |
| + store, err := activator.NewHeartbeatStore(context.Background(), hc.Bucket, hc.meshPrefix(), hc.Region) |
| + if err != nil { |
| + return nil, fmt.Errorf("mesh heartbeat store: %w", err) |
| + } |
| + return newMesh(self, store), nil |
| +} |
| + |
| +// start registers this box (one synchronous beat, so the first sweep and the |
| +// first request already see a membership view) and begins the heartbeat loop. |
| +func (m *meshSvc) start() { |
| + m.beat() |
| + m.mu.Lock() |
| + m.started = true |
| + m.mu.Unlock() |
| + go func() { |
| + defer close(m.done) |
| + t := time.NewTicker(m.interval) |
| + defer t.Stop() |
| + for { |
| + select { |
| + case <-m.stopCh: |
| + return |
| + case <-t.C: |
| + m.beat() |
| + } |
| + } |
| + }() |
| +} |
| + |
| +// beat PUTs our heartbeat and refreshes the member view. Both failures are |
| +// soft by design: a missed PUT makes peers see us stale (they take over; the |
| +// lease arbitrates), a failed LIST keeps the last known view (a stale view |
| +// misroutes at worst — same arbitration). |
| +func (m *meshSvc) beat() { |
| + timeout := m.interval |
| + if timeout < 5*time.Second { |
| + timeout = 5 * time.Second |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), timeout) |
| + defer cancel() |
| + if err := m.store.Put(ctx, m.self); err != nil { |
| + log.Printf("mesh: heartbeat put failed: %v — peers will see this box stale after %s", err, m.staleAfter) |
| + } |
| + members, err := m.store.List(ctx) |
| + if err != nil { |
| + log.Printf("mesh: membership list failed: %v — keeping the last known view", err) |
| + return |
| + } |
| + m.mu.Lock() |
| + m.members = members |
| + m.mu.Unlock() |
| +} |
| + |
| +// halt stops the heartbeat loop without touching the heartbeat object — the |
| +// shape a crashed box leaves behind (its record rots until staleAfter). The |
| +// drain path is stop; tests use halt to simulate hard death. |
| +func (m *meshSvc) halt() { |
| + m.mu.Lock() |
| + started := m.started |
| + m.started = false |
| + m.mu.Unlock() |
| + if !started { |
| + return |
| + } |
| + close(m.stopCh) |
| + <-m.done |
| +} |
| + |
| +// stop is the drain path: halt the loop (a beat racing the delete would |
| +// resurrect us), then delete our heartbeat so peers take over immediately. |
| +// The caller (activatorSvc.shutdown) checkpoints and releases AFTER this — |
| +// that ordering is the drain contract. |
| +func (m *meshSvc) stop(ctx context.Context) { |
| + m.halt() |
| + if err := m.store.Delete(ctx, m.self); err != nil { |
| + log.Printf("mesh: heartbeat delete failed: %v — peers take over when it goes stale (%s)", err, m.staleAfter) |
| + } |
| +} |
| + |
| +// liveBoxes is the current membership: every fresh heartbeat with a sane peer |
| +// URL, self always included — our own PUT may not have landed or listed yet, |
| +// and a box always counts itself, so the set is never empty and hrwOwner is |
| +// always defined. Staleness is applied at read time, so a box that dies |
| +// between polls drops out the moment its record ages past staleAfter. |
| +func (m *meshSvc) liveBoxes() []string { |
| + now := m.now() |
| + m.mu.Lock() |
| + members := m.members |
| + m.mu.Unlock() |
| + boxes := []string{m.self} |
| + for _, hb := range members { |
| + if hb.Peer == m.self || now.Sub(hb.LastSeen) > m.staleAfter { |
| + continue |
| + } |
| + // A garbage record (someone else's object decoding to nonsense) must |
| + // not enter the hash — it would attract ~1/N of hosts and blackhole |
| + // them behind an unproxyable "peer". |
| + if u, err := url.Parse(hb.Peer); err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { |
| + continue |
| + } |
| + boxes = append(boxes, hb.Peer) |
| + } |
| + return boxes |
| +} |
| + |
| +// owns reports whether this box is host's HRW owner in the live member set — |
| +// the due-time sweeper's gate: only the owner due-wakes a host. |
| +func (m *meshSvc) owns(host string) bool { |
| + return hrwOwner(host, m.liveBoxes()) == m.self |
| +} |
| + |
| +// remoteOwner returns the peer to proxy host to when its HRW owner is another |
| +// live box. ok=false means serve locally: we are the owner, or the previous |
| +// owner is dead/stale (it's out of the member set, so the hash has already |
| +// reassigned — possibly to us — and a local wake is the failover). |
| +func (m *meshSvc) remoteOwner(host string) (peer string, ok bool) { |
| + if o := hrwOwner(host, m.liveBoxes()); o != m.self { |
| + return o, true |
| + } |
| + return "", false |
| +} |
| + |
| +// proxyTo forwards one request to the owning peer, instance hostname |
| +// preserved. Proxies are built lazily, one per peer, and live for the mesh's |
| +// life — peer URLs are stable box identities. |
| +func (m *meshSvc) proxyTo(w http.ResponseWriter, r *http.Request, peer string) { |
| + m.mu.Lock() |
| + p := m.proxies[peer] |
| + if p == nil { |
| + u, err := url.Parse(peer) |
| + if err != nil || u.Host == "" { |
| + // liveBoxes filters garbage, so this is belt and braces. |
| + m.mu.Unlock() |
| + log.Printf("mesh: unusable peer URL %q for %s", peer, r.Host) |
| + http.Error(w, "instance's owner box is unreachable; retry shortly", http.StatusServiceUnavailable) |
| + return |
| + } |
| + p = newPeerProxy(m.self, u) |
| + m.proxies[peer] = p |
| + } |
| + m.mu.Unlock() |
| + p.ServeHTTP(w, r) |
| +} |
| + |
| +// newPeerProxy builds the reverse proxy to one peer box. The peer routes by |
| +// Host exactly like the public front door, so the instance hostname must |
| +// survive the hop; the scheme comes from the peer URL and is honored: |
| +// |
| +// - http peers (a private network between boxes): dial the peer address, |
| +// Host header = the instance host. |
| +// - https peers: SNI *and* certificate verification run against the |
| +// instance hostname — the peer presents its wildcard/per-host cert, which |
| +// proves it serves this host (it could never prove it IS 10.x.y.z). The |
| +// outgoing URL keeps the instance host and a custom dialer pins the TCP |
| +// connection to the peer's address — the same shape as the box's own |
| +// /etc/hosts loopback pin. InsecureSkipVerify is never used. |
| +func newPeerProxy(self string, peer *url.URL) *httputil.ReverseProxy { |
| + dialAddr := peerDialAddr(peer) |
| + return &httputil.ReverseProxy{ |
| + Rewrite: func(pr *httputil.ProxyRequest) { |
| + pr.SetXForwarded() |
| + pr.Out.URL.Scheme = peer.Scheme |
| + if peer.Scheme == "https" { |
| + pr.Out.URL.Host = pr.In.Host // SNI + cert verification target |
| + } else { |
| + pr.Out.URL.Host = peer.Host |
| + } |
| + pr.Out.Host = pr.In.Host // the peer routes by Host, like any client |
| + pr.Out.Header.Set(meshForwardedHeader, self) |
| + }, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { |
| + return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, dialAddr) |
| + }, |
| + // Bounded like unixProxy's pool, for the same leak reasons. |
| + MaxIdleConns: 32, |
| + IdleConnTimeout: 90 * time.Second, |
| + }, |
| + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { |
| + // An owner that died but hasn't gone stale yet (or a box-to-box |
| + // network fault): 503 + retry. Within staleAfter the membership |
| + // view drops it and retries wake the instance locally instead — |
| + // staleness bounds this window. |
| + log.Printf("mesh: proxy %s to %s: %v", r.Host, peer, err) |
| + w.Header().Set("Retry-After", "5") |
| + http.Error(w, "instance's owner box is unreachable; retry shortly", http.StatusServiceUnavailable) |
| + }, |
| + } |
| +} |
| + |
| +// peerDialAddr is the TCP address a peer base URL dials to, default ports |
| +// applied — computed once per proxy, since the peer URL never changes. |
| +func peerDialAddr(peer *url.URL) string { |
| + if peer.Port() != "" { |
| + return peer.Host |
| + } |
| + if peer.Scheme == "https" { |
| + return peer.Hostname() + ":443" |
| + } |
| + return peer.Hostname() + ":80" |
| +} |
| + |
| +// hrwOwner picks host's owner from boxes by rendezvous (highest-random-weight) |
| +// hashing: the box with the highest hash(box||host) wins. A pure function of |
| +// (host, member set) — every box computes the same answer from membership |
| +// alone, and when a box joins or leaves only ~1/N of hosts change owner (a |
| +// joiner only steals, a leaver's hosts scatter over the survivors; nothing |
| +// else moves). The result is a placement HINT, not a lock: the S3 lease stays |
| +// the single-writer truth, which is why this can be twenty lines. |
| +func hrwOwner(host string, boxes []string) string { |
| + var best string |
| + var bestScore uint64 |
| + for _, b := range boxes { |
| + h := fnv.New64a() |
| + io.WriteString(h, b) |
| + h.Write([]byte{0}) // separator: ("ab","c") must not collide with ("a","bc") |
| + io.WriteString(h, host) |
| + if s := h.Sum64(); best == "" || s > bestScore || (s == bestScore && b > best) { |
| + best, bestScore = b, s |
| + } |
| + } |
| + return best |
| +} |
diff --git a/mesh_live_test.go b/mesh_live_test.go| new file mode 100644 |
| index 0000000..c3ade11 |
| --- /dev/null |
| +++ b/mesh_live_test.go |
| @@ -0,0 +1,365 @@ |
| +package main |
| + |
| +// 🤖 The live two-box mesh tests ("the kill-a-box demo", #550 Part 4 v2 |
| +// Phase 3): TWO in-process boxes — two activator+router pairs on different |
| +// loopback ports, each spawning REAL instance processes — sharing one REAL S3 |
| +// bucket for leases, replicas, and mesh heartbeats. They prove the mesh's |
| +// whole story: |
| +// |
| +// - a request landing on the NON-owner box is reverse-proxied to the HRW |
| +// owner, which wakes the instance and serves; |
| +// - killing the owner box moves its instance to the survivor with data |
| +// intact — in BOTH kill flavors: |
| +// drain (TestMeshLiveProxyAndDrainFailover): heartbeat deleted |
| +// first, checkpoint + lease release — the survivor takes |
| +// over on its next membership poll; |
| +// hard kill (TestMeshLiveHardKillFailover): the box just dies — its |
| +// heartbeat rots to staleness, its lease expires on TTL, and |
| +// the survivor's wake is gated by the lease until then. |
| +// |
| +// Gated on LCHAT_TEST_S3_BUCKET like the other live tests. Run with: |
| +// |
| +// eval "$(aws configure export-credentials --profile eleven-dev --format env)" |
| +// LCHAT_TEST_S3_BUCKET=<bucket> go test -run TestMeshLive -v . |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/activator" |
| +) |
| + |
| +// liveMeshBox is one box of the pair: its own activator (real s3Backend, real |
| +// child processes), registry, mesh, and front door. |
| +type liveMeshBox struct { |
| + name string |
| + self string // the front URL — this box's mesh identity |
| + act *activatorSvc |
| + mesh *meshSvc |
| + front *httptest.Server |
| + reg *Registry |
| + route Route |
| + |
| + shutdownOnce sync.Once |
| +} |
| + |
| +// shutdown drains the box exactly once — tests that dismantle a box by hand |
| +// (the hard kill) consume the Once so cleanup can't double-drain it. |
| +func (b *liveMeshBox) shutdown(ctx context.Context) { |
| + b.shutdownOnce.Do(func() { b.act.shutdown(ctx) }) |
| +} |
| + |
| +// setupMeshLive builds the shared world (S3 prefix, staged binary, env file) |
| +// and the two boxes. Both boxes register the same host, each pointing at its |
| +// OWN socket and DB path — a box must never see its peer's local files, so a |
| +// failover's restore provably comes from S3. |
| +func setupMeshLive(t *testing.T, host string) (boxA, boxB *liveMeshBox, adminToken string) { |
| + t.Helper() |
| + bucket := os.Getenv("LCHAT_TEST_S3_BUCKET") |
| + if bucket == "" { |
| + t.Skip("LCHAT_TEST_S3_BUCKET not set; skipping live two-box mesh test") |
| + } |
| + region := os.Getenv("LCHAT_TEST_S3_REGION") |
| + if region == "" { |
| + region = "eu-west-1" |
| + } |
| + rb := make([]byte, 4) |
| + _, _ = rand.Read(rb) |
| + prefix := fmt.Sprintf("mesh-live/%d-%s", time.Now().Unix(), hex.EncodeToString(rb)) |
| + t.Cleanup(func() { cleanupS3Prefix(t, bucket, prefix, region) }) |
| + |
| + // Sandbox the box-layout globals (shared by both in-process boxes: the |
| + // staged binary, live symlink, and the instance env file are box-agnostic). |
| + dataDir := t.TempDir() |
| + sockDir := fmt.Sprintf("/tmp/lchat-mesh-%d", os.Getpid()) |
| + if err := os.MkdirAll(sockDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(sockDir) }) |
| + optTmp := t.TempDir() |
| + oldData, oldSock, oldOpt := instanceDataDir, instanceSocketDir, optDir |
| + instanceDataDir, instanceSocketDir, optDir = dataDir, sockDir, optTmp |
| + t.Cleanup(func() { instanceDataDir, instanceSocketDir, optDir = oldData, oldSock, oldOpt }) |
| + |
| + bin := filepath.Join(optTmp, "lchat-built") |
| + build := exec.Command("go", "build", "-o", bin, ".") |
| + build.Env = append(os.Environ(), "GOTOOLCHAIN=auto") |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("go build: %v\n%s", err, out) |
| + } |
| + if err := stageVersion(bin, "meshlive"); err != nil { |
| + t.Fatalf("stage: %v", err) |
| + } |
| + if err := pointLive(host, "meshlive"); err != nil { |
| + t.Fatalf("point live: %v", err) |
| + } |
| + |
| + adminToken = "meshlive-" + hex.EncodeToString(rb) |
| + if err := writeEnvFile(filepath.Join(dataDir, host+".env"), |
| + map[string]string{"LCHAT_ADMIN_TOKEN": adminToken}); err != nil { |
| + t.Fatalf("env file: %v", err) |
| + } |
| + |
| + cfg := hibernateConfig{Bucket: bucket, Region: region, Prefix: prefix, Idle: 2 * time.Minute} |
| + newBox := func(name string) *liveMeshBox { |
| + box := &liveMeshBox{name: name} |
| + var err error |
| + box.reg, err = openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + dbDir := filepath.Join(dataDir, name) |
| + if err := os.MkdirAll(dbDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + box.route = Route{Host: host, Kind: "instance", |
| + Socket: filepath.Join(sockDir, name+".sock"), |
| + DBPath: filepath.Join(dbDir, host+".db"), |
| + Hibernate: true} |
| + if err := box.reg.upsert(box.route); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + box.act = newActivator(cfg, &s3Backend{cfg: cfg}, box.reg) |
| + rt, err := newRouter(box.reg, box.act) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + box.front = httptest.NewServer(rt) |
| + t.Cleanup(box.front.Close) |
| + box.self = box.front.URL |
| + |
| + hb, err := activator.NewHeartbeatStore(context.Background(), bucket, cfg.meshPrefix(), region) |
| + if err != nil { |
| + t.Fatalf("heartbeat store: %v", err) |
| + } |
| + box.mesh = newMesh(box.self, hb) |
| + box.mesh.interval = time.Second |
| + box.mesh.staleAfter = 3 * time.Second |
| + box.act.mesh = box.mesh |
| + box.mesh.start() |
| + box.act.start() |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| + defer cancel() |
| + box.shutdown(ctx) |
| + }) |
| + return box |
| + } |
| + boxA, boxB = newBox("box-a"), newBox("box-b") |
| + |
| + // Both boxes must see the full membership before the test routes anything. |
| + waitForLive(t, "both boxes to see each other", 15*time.Second, func() bool { |
| + return len(boxA.mesh.liveBoxes()) == 2 && len(boxB.mesh.liveBoxes()) == 2 |
| + }) |
| + return boxA, boxB, adminToken |
| +} |
| + |
| +// pickOwner orders the pair as (owner, survivor) for host by the same HRW |
| +// hash the boxes themselves use. |
| +func pickOwner(host string, a, b *liveMeshBox) (owner, survivor *liveMeshBox) { |
| + if hrwOwner(host, []string{a.self, b.self}) == b.self { |
| + return b, a |
| + } |
| + return a, b |
| +} |
| + |
| +func meshGet(t *testing.T, front, host, path string) (int, string) { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", front+path, nil) |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s: %v", path, err) |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + return resp.StatusCode, string(body) |
| +} |
| + |
| +func meshSetGroupName(t *testing.T, front, host, token, name string) { |
| + t.Helper() |
| + body, _ := json.Marshal(map[string]string{"name": name}) |
| + req, _ := http.NewRequest("POST", front+"/api/admin/group", bytes.NewReader(body)) |
| + req.Host = host |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("set group name: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != 200 { |
| + b, _ := io.ReadAll(resp.Body) |
| + t.Fatalf("set group name: %d %s", resp.StatusCode, b) |
| + } |
| +} |
| + |
| +// pollUntilServed hammers the survivor's front door until the instance |
| +// answers 200, logging every distinct (status, body) transition — the test's |
| +// narrative of the failover: proxy-to-dead-peer 503s, then lease-held 503s, |
| +// then the wake. Returns the winning body. |
| +func pollUntilServed(t *testing.T, front, host string, deadline time.Duration) string { |
| + t.Helper() |
| + start := time.Now() |
| + last := "" |
| + for time.Now().Before(start.Add(deadline)) { |
| + code, body := meshGet(t, front, host, "/api/group") |
| + if code == 200 { |
| + t.Logf(" +%s: 200 — served", time.Since(start).Truncate(time.Millisecond)) |
| + return body |
| + } |
| + if cur := fmt.Sprintf("%d %s", code, strings.TrimSpace(body)); cur != last { |
| + t.Logf(" +%s: %s", time.Since(start).Truncate(time.Millisecond), cur) |
| + last = cur |
| + } |
| + time.Sleep(time.Second) |
| + } |
| + t.Fatalf("instance never served on the survivor within %s", deadline) |
| + return "" |
| +} |
| + |
| +// The drain variant: proxy-to-owner, then an orderly box exit (heartbeat |
| +// deleted FIRST, checkpoint, lease release) and the survivor taking over on |
| +// its next membership poll — the same code path a deploy or scale-down runs. |
| +func TestMeshLiveProxyAndDrainFailover(t *testing.T) { |
| + const host = "mesh-drain.test" |
| + boxA, boxB, adminToken := setupMeshLive(t, host) |
| + owner, survivor := pickOwner(host, boxA, boxB) |
| + t.Logf("HRW owner of %s is %s (%s); survivor is %s (%s)", host, owner.name, owner.self, survivor.name, survivor.self) |
| + |
| + // --- Act 1: a request to the NON-owner proxies to the owner and serves. --- |
| + start := time.Now() |
| + code, _ := meshGet(t, survivor.front.URL, host, "/api/group") |
| + if code != 200 { |
| + t.Fatalf("request via non-owner: %d, want 200", code) |
| + } |
| + t.Logf("request to the non-owner proxied + woke the instance on %s in %s", owner.name, time.Since(start).Truncate(time.Millisecond)) |
| + if owner.act.entryCount() != 1 || !owner.act.entryAwake(host) { |
| + t.Fatalf("instance is not awake on the owner (entries=%d)", owner.act.entryCount()) |
| + } |
| + if survivor.act.entryCount() != 0 { |
| + t.Fatal("the non-owner woke a local copy instead of proxying") |
| + } |
| + |
| + // Write real data through the proxied path. |
| + groupName := "Mesh failover " + hex.EncodeToString([]byte(host)) |
| + meshSetGroupName(t, survivor.front.URL, host, adminToken, groupName) |
| + t.Logf("wrote group name %q through the non-owner's front door", groupName) |
| + |
| + // --- Act 2: drain the owner. Front door dark first (runRouter shuts the |
| + // HTTP servers down before the activator — a draining box accepts no new |
| + // work), then the drain proper: heartbeat delete, checkpoint, release. --- |
| + start = time.Now() |
| + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| + defer cancel() |
| + owner.front.Close() |
| + owner.shutdown(ctx) |
| + t.Logf("owner %s drained in %s (heartbeat deleted, instance checkpointed, lease released)", owner.name, time.Since(start).Truncate(time.Millisecond)) |
| + |
| + // The heartbeat really is gone from the bucket — only the survivor beats. |
| + hbs, err := survivor.mesh.store.List(context.Background()) |
| + if err != nil { |
| + t.Fatalf("list heartbeats: %v", err) |
| + } |
| + for _, hb := range hbs { |
| + if hb.Peer == owner.self { |
| + t.Fatal("drained owner's heartbeat is still in the bucket") |
| + } |
| + } |
| + |
| + // --- Act 3: the survivor takes over — wake local, restore from S3. --- |
| + body := pollUntilServed(t, survivor.front.URL, host, 60*time.Second) |
| + if !strings.Contains(body, groupName) { |
| + t.Fatalf("survivor lost the data: %s does not contain %q", body, groupName) |
| + } |
| + if !survivor.act.entryAwake(host) { |
| + t.Fatal("instance is not awake on the survivor") |
| + } |
| + t.Logf("survivor %s restored the instance from S3 with data intact: %s", survivor.name, body) |
| +} |
| + |
| +// The hard-kill variant: the owner box dies with no goodbye — its heartbeat |
| +// is left to rot (stale in staleAfter) and its lease to expire (TTL). The |
| +// survivor's requests walk the whole failure narrative: proxy to the corpse |
| +// (503), local wake gated by the still-held lease (503 + Retry-After), then |
| +// the lease expires and the instance wakes on the survivor with every byte |
| +// that had replicated. |
| +func TestMeshLiveHardKillFailover(t *testing.T) { |
| + const host = "mesh-kill.test" |
| + boxA, boxB, adminToken := setupMeshLive(t, host) |
| + owner, survivor := pickOwner(host, boxA, boxB) |
| + t.Logf("HRW owner of %s is %s (%s); survivor is %s (%s)", host, owner.name, owner.self, survivor.name, survivor.self) |
| + |
| + // --- Act 1: wake on the owner and write data. --- |
| + if code, _ := meshGet(t, owner.front.URL, host, "/api/group"); code != 200 { |
| + t.Fatalf("wake on owner: %d, want 200", code) |
| + } |
| + groupName := "Hard kill survivor " + hex.EncodeToString([]byte(host)) |
| + meshSetGroupName(t, owner.front.URL, host, adminToken, groupName) |
| + |
| + // Make the write durable in S3 before the "crash" — a real hard kill |
| + // loses only the last seconds of unshipped WAL (that's the design's RPO); |
| + // the test pins the data so the assertion is about failover, not timing. |
| + owner.act.mu.Lock() |
| + entry := owner.act.entries[host] |
| + owner.act.mu.Unlock() |
| + if entry == nil { |
| + t.Fatal("no live entry on the owner") |
| + } |
| + rep := entry.rep.(*activator.LeasedReplicator) |
| + syncCtx, syncCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + if err := rep.SyncAndWait(syncCtx); err != nil { |
| + syncCancel() |
| + t.Fatalf("sync before kill: %v", err) |
| + } |
| + syncCancel() |
| + t.Logf("wrote group name %q on the owner and confirmed it replicated to S3", groupName) |
| + |
| + // --- Act 2: hard-kill the owner box. Everything at once, nothing orderly: |
| + // front door gone, sweeper gone, heartbeat loop gone (object NOT deleted), |
| + // instance child SIGKILLed, lease renewal abandoned (NOT released). --- |
| + owner.front.Close() |
| + close(owner.act.sweepStop) |
| + <-owner.act.sweepDone |
| + owner.mesh.halt() // the heartbeat object stays — it must rot to staleness |
| + owner.act.mu.Lock() |
| + delete(owner.act.entries, host) |
| + owner.act.mu.Unlock() |
| + proc := entry.proc.(*childProc) |
| + if err := proc.cmd.Process.Kill(); err != nil { |
| + t.Fatalf("SIGKILL instance child: %v", err) |
| + } |
| + <-proc.done |
| + abandonCtx, abandonCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + rep.Abandon(abandonCtx) |
| + abandonCancel() |
| + owner.shutdownOnce.Do(func() {}) // consume: cleanup must not re-drain a corpse |
| + t.Logf("owner %s hard-killed: heartbeat left in S3 (stale after %s), lease left to expire (TTL %s)", |
| + owner.name, survivor.mesh.staleAfter, activator.LeaseTTL) |
| + |
| + // --- Act 3: the survivor takes over once staleness + lease expiry pass. --- |
| + start := time.Now() |
| + body := pollUntilServed(t, survivor.front.URL, host, 120*time.Second) |
| + if !strings.Contains(body, groupName) { |
| + t.Fatalf("survivor lost the data: %s does not contain %q", body, groupName) |
| + } |
| + if !survivor.act.entryAwake(host) { |
| + t.Fatal("instance is not awake on the survivor") |
| + } |
| + t.Logf("survivor %s took over %s after the owner's death with data intact: %s", |
| + survivor.name, time.Since(start).Truncate(time.Millisecond), body) |
| +} |
diff --git a/mesh_test.go b/mesh_test.go| new file mode 100644 |
| index 0000000..2929265 |
| --- /dev/null |
| +++ b/mesh_test.go |
| @@ -0,0 +1,633 @@ |
| +package main |
| + |
| +// Unit + in-process integration tests for the activator mesh (mesh.go): HRW |
| +// determinism and minimal redistribution, membership staleness, the |
| +// owner-vs-proxy routing decision, proxy header/Host semantics, drain |
| +// ordering (heartbeat first), the due-sweep ownership gate, and the mesh-off |
| +// tripwire. Two "boxes" here are two activator+router pairs sharing an |
| +// in-memory heartbeat store — the store IS the bucket, which is the whole |
| +// membership design. The real-S3, real-process version (proxy-to-owner and |
| +// the kill-a-box failover, drain and hard-kill variants) is in |
| +// mesh_live_test.go. |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/http/httputil" |
| + "net/url" |
| + "path/filepath" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/activator" |
| +) |
| + |
| +// --- fakes --------------------------------------------------------------------- |
| + |
| +// fakeMeshStore is an in-memory stand-in for the S3 heartbeat prefix. Shared |
| +// between boxes it plays the bucket; rec (optional) feeds a fakeBackend's |
| +// order log so drain tests can pin event ordering. |
| +type fakeMeshStore struct { |
| + mu sync.Mutex |
| + beats map[string]time.Time |
| + now func() time.Time |
| + rec func(ev string) |
| +} |
| + |
| +func newFakeMeshStore(now func() time.Time) *fakeMeshStore { |
| + return &fakeMeshStore{beats: make(map[string]time.Time), now: now} |
| +} |
| + |
| +func (s *fakeMeshStore) Put(_ context.Context, peer string) error { |
| + s.mu.Lock() |
| + s.beats[peer] = s.now() |
| + rec := s.rec |
| + s.mu.Unlock() |
| + if rec != nil { |
| + rec("hb-put") |
| + } |
| + return nil |
| +} |
| + |
| +func (s *fakeMeshStore) List(context.Context) ([]activator.Heartbeat, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + out := make([]activator.Heartbeat, 0, len(s.beats)) |
| + for peer, seen := range s.beats { |
| + out = append(out, activator.Heartbeat{Peer: peer, LastSeen: seen}) |
| + } |
| + return out, nil |
| +} |
| + |
| +func (s *fakeMeshStore) Delete(_ context.Context, peer string) error { |
| + s.mu.Lock() |
| + delete(s.beats, peer) |
| + rec := s.rec |
| + s.mu.Unlock() |
| + if rec != nil { |
| + rec("hb-delete") |
| + } |
| + return nil |
| +} |
| + |
| +func (s *fakeMeshStore) setBeat(peer string, at time.Time) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + s.beats[peer] = at |
| +} |
| + |
| +func (s *fakeMeshStore) hasBeat(peer string) bool { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + _, ok := s.beats[peer] |
| + return ok |
| +} |
| + |
| +// hostOwnedBy generates deterministic host names until one's HRW owner is the |
| +// wanted box — tests can't hardcode hosts because box identities (httptest |
| +// ports) vary per run. |
| +func hostOwnedBy(boxes []string, owner string) string { |
| + for i := 0; ; i++ { |
| + h := fmt.Sprintf("space-%d.test", i) |
| + if hrwOwner(h, boxes) == owner { |
| + return h |
| + } |
| + } |
| +} |
| + |
| +// meshTestBox is one in-process "box": activator (fake backend) + router + |
| +// front door + a live unix-socket listener standing in for any woken instance |
| +// (it echoes the box's name and records what the backend saw). |
| +type meshTestBox struct { |
| + name string |
| + act *activatorSvc |
| + b *fakeBackend |
| + reg *Registry |
| + rt *router |
| + front *httptest.Server |
| + mesh *meshSvc |
| + sock string |
| + |
| + seenHost atomic.Value // last Host the backend instance saw |
| + fwdLeaked atomic.Bool // the forwarded marker reached the backend |
| +} |
| + |
| +func (box *meshTestBox) self() string { return box.front.URL } |
| + |
| +func newMeshTestBox(t *testing.T, name string, store meshStore, clk *fakeClock) *meshTestBox { |
| + t.Helper() |
| + box := &meshTestBox{name: name, b: &fakeBackend{}} |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + box.reg = reg |
| + box.act = newActivator(hibernateConfig{Bucket: "test-bucket", Idle: time.Minute}, box.b, reg) |
| + box.act.now = clk.now |
| + |
| + box.sock = tmpSock(t) |
| + ln, err := net.Listen("unix", box.sock) |
| + if err != nil { |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + box.seenHost.Store(r.Host) |
| + if r.Header.Get(meshForwardedHeader) != "" { |
| + box.fwdLeaked.Store(true) |
| + } |
| + io.WriteString(w, name) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + box.rt, err = newRouter(reg, box.act) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + box.front = httptest.NewServer(box.rt) |
| + t.Cleanup(box.front.Close) |
| + |
| + box.mesh = newMesh(box.front.URL, store) |
| + box.mesh.now = clk.now |
| + box.act.mesh = box.mesh |
| + return box |
| +} |
| + |
| +// addHost registers a hibernating route for host on this box (pointing at its |
| +// stand-in instance socket) and reloads the router. |
| +func (box *meshTestBox) addHost(t *testing.T, host string) { |
| + t.Helper() |
| + if err := box.reg.upsert(Route{Host: host, Kind: "instance", Socket: box.sock, Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := box.rt.reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| +} |
| + |
| +// getVia is proxyGet plus arbitrary headers. |
| +func getVia(t *testing.T, base, host string, hdr map[string]string) (string, int) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, base+"/", nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + for k, v := range hdr { |
| + req.Header.Set(k, v) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + return string(b), resp.StatusCode |
| +} |
| + |
| +// --- rendezvous hashing ---------------------------------------------------------- |
| + |
| +// hrwOwner is a pure function: same answer for any ordering of the member |
| +// set, on every box, forever — that's what lets placement need no directory. |
| +func TestHRWOwnerDeterministic(t *testing.T) { |
| + boxes := []string{"http://a", "http://b", "http://c"} |
| + perms := [][]string{ |
| + {"http://a", "http://b", "http://c"}, |
| + {"http://c", "http://a", "http://b"}, |
| + {"http://b", "http://c", "http://a"}, |
| + } |
| + owned := map[string]int{} |
| + for i := 0; i < 300; i++ { |
| + h := fmt.Sprintf("host-%d.test", i) |
| + want := hrwOwner(h, boxes) |
| + for _, p := range perms { |
| + if got := hrwOwner(h, p); got != want { |
| + t.Fatalf("owner of %s depends on member order: %q vs %q", h, got, want) |
| + } |
| + } |
| + owned[want]++ |
| + } |
| + // The hash must actually spread load — every box owns a healthy share. |
| + for _, b := range boxes { |
| + if owned[b] < 50 { |
| + t.Fatalf("box %s owns only %d/300 hosts — distribution is broken: %v", b, owned[b], owned) |
| + } |
| + } |
| + if hrwOwner("x.test", []string{"http://solo"}) != "http://solo" { |
| + t.Fatal("single box does not own everything") |
| + } |
| + if hrwOwner("x.test", nil) != "" { |
| + t.Fatal("empty member set should own nothing") |
| + } |
| +} |
| + |
| +// The property that makes membership churn cheap: adding a box moves only the |
| +// ~1/N of hosts it steals (all TO the joiner), removing one moves only the |
| +// leaver's hosts (scattered over survivors) — everyone else stays put, so a |
| +// scale event never stampedes the fleet. |
| +func TestHRWRedistributionMinimal(t *testing.T) { |
| + four := []string{"http://a", "http://b", "http://c", "http://d"} |
| + five := append(append([]string{}, four...), "http://e") |
| + const n = 1000 |
| + |
| + before := make(map[string]string, n) |
| + for i := 0; i < n; i++ { |
| + h := fmt.Sprintf("host-%d.test", i) |
| + before[h] = hrwOwner(h, four) |
| + } |
| + |
| + // Join: every moved host must have moved onto the new box. |
| + moved := 0 |
| + for h, was := range before { |
| + now := hrwOwner(h, five) |
| + if now != was { |
| + moved++ |
| + if now != "http://e" { |
| + t.Fatalf("join moved %s from %s to %s — only the joiner may steal", h, was, now) |
| + } |
| + } |
| + } |
| + if moved < n/10 || moved > n/2 { |
| + t.Fatalf("join moved %d/%d hosts, want ~1/5 (a fifth box should steal about its share)", moved, n) |
| + } |
| + |
| + // Leave: only the leaver's hosts move. |
| + three := []string{"http://a", "http://c", "http://d"} |
| + for h, was := range before { |
| + now := hrwOwner(h, three) |
| + if was == "http://b" { |
| + if now == "http://b" { |
| + t.Fatalf("%s still owned by the removed box", h) |
| + } |
| + } else if now != was { |
| + t.Fatalf("leave moved %s from %s to %s — hosts not owned by the leaver must not move", h, was, now) |
| + } |
| + } |
| +} |
| + |
| +// --- membership -------------------------------------------------------------- |
| + |
| +// Staleness: a heartbeat older than staleAfter means a dead box — judged at |
| +// read time, so a box that dies between polls drops out on the clock, not on |
| +// the next LIST. Self is always a member (a box counts itself even before its |
| +// first PUT lands), and garbage records never enter the hash. |
| +func TestMeshMembershipStaleness(t *testing.T) { |
| + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| + store := newFakeMeshStore(clk.now) |
| + m := newMesh("http://a", store) |
| + m.now = clk.now |
| + |
| + // Before any beat: membership of one — the degenerate single box. |
| + if got := m.liveBoxes(); len(got) != 1 || got[0] != "http://a" { |
| + t.Fatalf("liveBoxes before any beat = %v, want just self", got) |
| + } |
| + |
| + store.setBeat("http://b", clk.now()) |
| + store.setBeat("this is not a url", clk.now()) // a foreign/garbage record |
| + m.beat() |
| + if !store.hasBeat("http://a") { |
| + t.Fatal("beat did not PUT our own heartbeat") |
| + } |
| + live := m.liveBoxes() |
| + if len(live) != 2 { |
| + t.Fatalf("liveBoxes = %v, want self+b (garbage excluded)", live) |
| + } |
| + |
| + // b goes quiet: past staleAfter it is dead — with NO new list needed. |
| + clk.advance(m.staleAfter + time.Second) |
| + if got := m.liveBoxes(); len(got) != 1 || got[0] != "http://a" { |
| + t.Fatalf("liveBoxes with a stale peer = %v, want just self", got) |
| + } |
| +} |
| + |
| +// The routing decision: proxy only when the HRW owner is another LIVE box; |
| +// own it (wake locally) when the owner is us — or dead, because a stale owner |
| +// is out of the member set and the hash has already reassigned. The lease |
| +// arbitrates if this view is ever wrong; the decision only places. |
| +func TestMeshRoutingDecision(t *testing.T) { |
| + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| + store := newFakeMeshStore(clk.now) |
| + m := newMesh("http://a", store) |
| + m.now = clk.now |
| + store.setBeat("http://b", clk.now()) |
| + m.beat() |
| + |
| + boxes := []string{"http://a", "http://b"} |
| + theirs := hostOwnedBy(boxes, "http://b") |
| + ours := hostOwnedBy(boxes, "http://a") |
| + |
| + if peer, remote := m.remoteOwner(theirs); !remote || peer != "http://b" { |
| + t.Fatalf("remoteOwner(%s) = %q, %v; want b, true", theirs, peer, remote) |
| + } |
| + if _, remote := m.remoteOwner(ours); remote { |
| + t.Fatalf("remoteOwner(%s) says proxy a host we own", ours) |
| + } |
| + if m.owns(theirs) || !m.owns(ours) { |
| + t.Fatal("owns() disagrees with remoteOwner()") |
| + } |
| + |
| + // The owner dies: its hosts fall through to a local wake. |
| + clk.advance(m.staleAfter + time.Second) |
| + if _, remote := m.remoteOwner(theirs); remote { |
| + t.Fatal("remoteOwner still proxies to a stale (dead) box — its hosts must wake locally") |
| + } |
| + if !m.owns(theirs) { |
| + t.Fatal("survivor does not own the dead box's host") |
| + } |
| +} |
| + |
| +// --- the proxy hop ------------------------------------------------------------- |
| + |
| +// Scheme/Host/SNI semantics of the peer hop: the instance hostname survives |
| +// (the peer routes by Host); for https peers the outgoing URL keeps the |
| +// instance host so SNI and cert verification run against a name the peer can |
| +// actually prove (its wildcard/per-host cert), while the dialer pins TCP to |
| +// the peer's address; for http peers the URL targets the peer directly. The |
| +// forwarded marker rides every hop. InsecureSkipVerify appears nowhere. |
| +func TestPeerProxyRewrite(t *testing.T) { |
| + rewrite := func(p *httputil.ReverseProxy, host string) *http.Request { |
| + in := httptest.NewRequest("GET", "http://placeholder/api/x", nil) |
| + in.Host = host |
| + in.RemoteAddr = "192.0.2.7:4711" |
| + out := in.Clone(context.Background()) |
| + p.Rewrite(&httputil.ProxyRequest{In: in, Out: out}) |
| + return out |
| + } |
| + |
| + httpsPeer, _ := url.Parse("https://box-b.internal") |
| + out := rewrite(newPeerProxy("http://self", httpsPeer), "space.11ch.at") |
| + if out.URL.Scheme != "https" || out.URL.Host != "space.11ch.at" || out.Host != "space.11ch.at" { |
| + t.Fatalf("https hop: scheme=%s urlHost=%s host=%s — SNI/verification must target the instance host", out.URL.Scheme, out.URL.Host, out.Host) |
| + } |
| + if got := peerDialAddr(httpsPeer); got != "box-b.internal:443" { |
| + t.Fatalf("https dial addr = %q, want the peer's address", got) |
| + } |
| + if out.Header.Get(meshForwardedHeader) != "http://self" { |
| + t.Fatal("forwarded marker missing — the one-hop loop guard would not hold") |
| + } |
| + |
| + httpPeer, _ := url.Parse("http://10.0.0.5:9000") |
| + out = rewrite(newPeerProxy("http://self", httpPeer), "space.11ch.at") |
| + if out.URL.Scheme != "http" || out.URL.Host != "10.0.0.5:9000" || out.Host != "space.11ch.at" { |
| + t.Fatalf("http hop: scheme=%s urlHost=%s host=%s — dial the peer, Host stays the instance", out.URL.Scheme, out.URL.Host, out.Host) |
| + } |
| + if got := peerDialAddr(httpPeer); got != "10.0.0.5:9000" { |
| + t.Fatalf("http dial addr = %q", got) |
| + } |
| + bare, _ := url.Parse("http://box-c.internal") |
| + if got := peerDialAddr(bare); got != "box-c.internal:80" { |
| + t.Fatalf("default http port: %q", got) |
| + } |
| +} |
| + |
| +// Two boxes, one shared "bucket": a request landing on the non-owner is |
| +// reverse-proxied to the owner (which wakes the instance), the instance |
| +// hostname survives the hop, the forwarded marker never reaches a backend, |
| +// a request already carrying the marker is served where it stands (one hop |
| +// max), and a host with a live local entry stays local even when the hash |
| +// points elsewhere. |
| +func TestMeshProxyToOwner(t *testing.T) { |
| + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| + store := newFakeMeshStore(clk.now) |
| + box1 := newMeshTestBox(t, "box1", store, clk) |
| + box2 := newMeshTestBox(t, "box2", store, clk) |
| + // Beat both (twice for box1, whose first beat predates box2's PUT) so |
| + // each box sees the full membership. |
| + box1.mesh.beat() |
| + box2.mesh.beat() |
| + box1.mesh.beat() |
| + |
| + boxes := []string{box1.self(), box2.self()} |
| + host := hostOwnedBy(boxes, box2.self()) |
| + box1.addHost(t, host) |
| + box2.addHost(t, host) |
| + |
| + // (a) Request to the NON-owner: proxied to the owner and served there. |
| + if body, code := proxyGet(t, box1.front.URL, host); code != 200 || body != "box2" { |
| + t.Fatalf("non-owner request: got %d %q, want 200 box2 (proxied to owner)", code, body) |
| + } |
| + if acq, _, _ := box2.b.counts(); acq != 1 { |
| + t.Fatalf("owner acquires = %d, want 1 (the wake happened on the owner)", acq) |
| + } |
| + if acq, _, starts := box1.b.counts(); acq != 0 || starts != 0 { |
| + t.Fatalf("non-owner touched its own activator (acquires=%d starts=%d)", acq, starts) |
| + } |
| + if got, _ := box2.seenHost.Load().(string); got != host { |
| + t.Fatalf("owner backend saw Host %q, want %q — the hostname must survive the hop", got, host) |
| + } |
| + if box2.fwdLeaked.Load() { |
| + t.Fatal("the forwarded marker leaked through to the backend") |
| + } |
| + |
| + // (b) Request straight to the owner: local, no extra wake. |
| + if body, code := proxyGet(t, box2.front.URL, host); code != 200 || body != "box2" { |
| + t.Fatalf("owner request: got %d %q", code, body) |
| + } |
| + if acq, _, _ := box2.b.counts(); acq != 1 { |
| + t.Fatalf("owner re-acquired for a host it already has awake (acquires=%d)", acq) |
| + } |
| + |
| + // (c) One hop max: a request already carrying the forwarded marker is |
| + // served where it stands — here the non-owner, which wakes it locally |
| + // (the lease would arbitrate a real conflict; the fake just observes). |
| + var host2 string // a second box2-owned host, distinct from the first |
| + for i := 0; ; i++ { |
| + if h := fmt.Sprintf("space-b-%d.test", i); hrwOwner(h, boxes) == box2.self() { |
| + host2 = h |
| + break |
| + } |
| + } |
| + box1.addHost(t, host2) |
| + box2.addHost(t, host2) |
| + if body, code := getVia(t, box1.front.URL, host2, map[string]string{meshForwardedHeader: "http://elsewhere"}); code != 200 || body != "box1" { |
| + t.Fatalf("forwarded request: got %d %q, want 200 box1 (served in place, never re-proxied)", code, body) |
| + } |
| + if acq, _, _ := box1.b.counts(); acq != 1 { |
| + t.Fatalf("non-owner acquires = %d after forwarded request, want 1", acq) |
| + } |
| + |
| + // (d) Live-local-entry rule: host2 is now awake on box1, so an ordinary |
| + // request to box1 stays local despite the hash pointing at box2 — an |
| + // awake instance finishes its life where it is. |
| + if body, code := proxyGet(t, box1.front.URL, host2); code != 200 || body != "box1" { |
| + t.Fatalf("live-entry request: got %d %q, want 200 box1 (local entry wins over the hint)", code, body) |
| + } |
| + if acq, _, _ := box2.b.counts(); acq != 1 { |
| + t.Fatalf("owner was woken for a host living on its peer (acquires=%d)", acq) |
| + } |
| +} |
| + |
| +// --- mesh off: the tripwire ------------------------------------------------------ |
| + |
| +// With the mesh off (LCHAT_MESH_SELF unset → act.mesh nil) the activator is |
| +// byte-identical to the single-box shape: a host whose HRW owner WOULD be a |
| +// peer is woken locally, nothing is proxied, and no heartbeat is ever |
| +// written. The same box with only the mesh flipped on proxies the same-shaped |
| +// request — proving the nil check is the single divergence point. |
| +func TestMeshOffTripwire(t *testing.T) { |
| + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| + store := newFakeMeshStore(clk.now) |
| + box1 := newMeshTestBox(t, "box1", store, clk) |
| + box2 := newMeshTestBox(t, "box2", store, clk) |
| + box1.act.mesh = nil // the tripwire: box1 is mesh-off |
| + box2.mesh.beat() // box2 participates; its heartbeat is in the store |
| + |
| + boxes := []string{box1.self(), box2.self()} |
| + host := hostOwnedBy(boxes, box2.self()) |
| + box1.addHost(t, host) |
| + box2.addHost(t, host) |
| + |
| + if body, code := proxyGet(t, box1.front.URL, host); code != 200 || body != "box1" { |
| + t.Fatalf("mesh-off request: got %d %q, want 200 box1 (woken locally, never proxied)", code, body) |
| + } |
| + if acq, _, _ := box1.b.counts(); acq != 1 { |
| + t.Fatalf("mesh-off box acquires = %d, want 1", acq) |
| + } |
| + if acq, _, _ := box2.b.counts(); acq != 0 { |
| + t.Fatalf("peer was touched by a mesh-off box (acquires=%d)", acq) |
| + } |
| + if store.hasBeat(box1.self()) { |
| + t.Fatal("mesh-off box wrote a heartbeat") |
| + } |
| + |
| + // Contrast: flip the mesh on — the ONLY change — and the same-shaped |
| + // request (a fresh host, since the first now has a live local entry) |
| + // proxies to the owner. |
| + box1.act.mesh = box1.mesh |
| + box1.mesh.beat() |
| + var host2 string |
| + for i := 0; ; i++ { |
| + h := fmt.Sprintf("contrast-%d.test", i) |
| + if hrwOwner(h, boxes) == box2.self() { |
| + host2 = h |
| + break |
| + } |
| + } |
| + box1.addHost(t, host2) |
| + box2.addHost(t, host2) |
| + if body, code := proxyGet(t, box1.front.URL, host2); code != 200 || body != "box2" { |
| + t.Fatalf("mesh-on contrast: got %d %q, want 200 box2 (proxied)", code, body) |
| + } |
| +} |
| + |
| +// meshFromEnv: unset = off (nil, nil — the zero-change default); set without |
| +// the hibernation bucket = a loud config error; malformed URLs rejected. |
| +func TestMeshFromEnv(t *testing.T) { |
| + t.Setenv("LCHAT_MESH_SELF", "") |
| + if m, err := meshFromEnv(&hibernateConfig{Bucket: "b"}); err != nil || m != nil { |
| + t.Fatalf("unset: got %v, %v; want nil, nil (mesh off)", m, err) |
| + } |
| + |
| + t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal") |
| + if _, err := meshFromEnv(nil); err == nil { |
| + t.Fatal("mesh without a hibernation bucket should be a config error") |
| + } |
| + |
| + for _, bad := range []string{"box-a.internal", "ftp://box-a", "https://box-a/path"} { |
| + t.Setenv("LCHAT_MESH_SELF", bad) |
| + if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil { |
| + t.Fatalf("LCHAT_MESH_SELF %q should be rejected", bad) |
| + } |
| + } |
| + |
| + t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal/") |
| + m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}) |
| + if err != nil || m == nil { |
| + t.Fatalf("valid self: %v, %v", m, err) |
| + } |
| + if m.self != "https://box-a.internal" { |
| + t.Fatalf("self = %q, want the trailing slash trimmed", m.self) |
| + } |
| +} |
| + |
| +// --- drain ordering --------------------------------------------------------------- |
| + |
| +// The drain contract, order-pinned: the heartbeat is deleted FIRST — before |
| +// the next-due probe, before the child stops, before the final checkpoint and |
| +// lease release — so peers take over the moment the leases free up. Same code |
| +// path for deploys, box failure drills, and scale-down. |
| +func TestMeshDrainDeletesHeartbeatFirst(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + store := newFakeMeshStore(clk.now) |
| + store.rec = b.record // interleave heartbeat ops with the backend's order log |
| + m := newMesh("http://box-a", store) |
| + m.now = clk.now |
| + m.interval = time.Hour // the loop must never beat on its own mid-test |
| + a.mesh = m |
| + m.start() |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("drain.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + a.shutdown(ctx) |
| + |
| + if store.hasBeat("http://box-a") { |
| + t.Fatal("drain left our heartbeat behind") |
| + } |
| + ev := b.events() |
| + del := indexOf(ev, "hb-delete") |
| + if del == -1 { |
| + t.Fatalf("no heartbeat delete in %v", ev) |
| + } |
| + if put := indexOf(ev, "hb-put"); put == -1 || put > del { |
| + t.Fatalf("heartbeat lifecycle out of order: %v", ev) |
| + } |
| + for _, later := range []string{"nextDue", "stop:instance"} { |
| + if i := indexOf(ev, later); i == -1 || i < del { |
| + t.Fatalf("drain order wrong: %q at %d, hb-delete at %d — the heartbeat must go first (%v)", later, i, del, ev) |
| + } |
| + } |
| +} |
| + |
| +// --- due-time sweeps under the mesh ----------------------------------------------- |
| + |
| +// Only the HRW owner due-wakes a host; a non-owner skips it every tick. When |
| +// the owner dies, staleness reassigns ownership and the next sweep fires the |
| +// wake here — failover for schedules is the same eventual path as requests. |
| +func TestMeshDueSweepOnlyOwner(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, 10*time.Second) |
| + store := newFakeMeshStore(clk.now) |
| + m := newMesh("http://box-a", store) |
| + m.now = clk.now |
| + a.mesh = m |
| + store.setBeat("http://box-b", clk.now()) |
| + m.beat() |
| + |
| + host := hostOwnedBy([]string{"http://box-a", "http://box-b"}, "http://box-b") |
| + route := testRoute(host) |
| + if err := reg.upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.setNextDue(host, clk.now().Add(-time.Second).Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The peer owns it: our sweeps must not wake it, however overdue. |
| + for i := 0; i < 3; i++ { |
| + a.sweepOnce() |
| + } |
| + if _, _, starts := b.counts(); starts != 0 { |
| + t.Fatalf("starts=%d — a non-owner due-woke a host", starts) |
| + } |
| + |
| + // The owner dies (heartbeat goes stale): ownership reassigns to us on the |
| + // next read, and the sweep fires the wake. |
| + clk.advance(m.staleAfter + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "post-failover due wake", func() bool { return a.entryAwake(host) }) |
| + if _, _, starts := b.counts(); starts != 1 { |
| + t.Fatalf("starts=%d after owner death, want 1", starts) |
| + } |
| +} |
diff --git a/router.go b/router.go| index 56503ee..d8ba225 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -162,8 +162,11 @@ func unixProxy(socketPath string) *httputil.ReverseProxy { |
| pr.Out.Host = pr.In.Host // backend sees the real hostname |
| // The sidecar marker is a router-edge signal only (activity |
| // exclusion, activator.go); strip it so nothing downstream can |
| - // ever grow a meaning an outsider could spoof. |
| + // ever grow a meaning an outsider could spoof. The mesh's |
| + // forwarded marker (one-hop loop guard, mesh.go) gets the same |
| + // treatment for the same reason. |
| pr.Out.Header.Del(bot.SidecarHeader) |
| + pr.Out.Header.Del(meshForwardedHeader) |
| }, |
| Transport: &http.Transport{ |
| DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| @@ -231,13 +234,31 @@ func runRouter(args []string) { |
| // Unset keeps the classic shape — instances under systemd, router as a |
| // pure proxy — so self-hosters and the current fleet are untouched. |
| var act *activatorSvc |
| - if hc, err := hibernateConfigFromEnv(); err != nil { |
| + hc, err := hibernateConfigFromEnv() |
| + if err != nil { |
| + log.Fatalf("router: %v", err) |
| + } |
| + // The activator mesh (mesh.go): opt-in via LCHAT_MESH_SELF, rides on the |
| + // hibernation bucket (meshFromEnv errors if it's set without one). Unset = |
| + // single box, no mesh codepaths. |
| + mesh, err := meshFromEnv(hc) |
| + if err != nil { |
| log.Fatalf("router: %v", err) |
| - } else if hc != nil { |
| + } |
| + if hc != nil { |
| // The registry doubles as the due-time index's store (routes.next_due), |
| // so scheduled deliveries survive router restarts and fire on time even |
| // for sleeping instances. |
| act = newActivator(*hc, &s3Backend{cfg: *hc}, reg) |
| + if mesh != nil { |
| + // Wired and beating before act.start(): the sweeper consults the |
| + // mesh from its first tick, and the first request deserves a |
| + // membership view. |
| + act.mesh = mesh |
| + mesh.start() |
| + log.Printf("router: activator mesh on — this box is %s (heartbeats s3://%s/%s every %s, peers stale after %s)", |
| + mesh.self, hc.Bucket, hc.meshPrefix(), mesh.interval, mesh.staleAfter) |
| + } |
| act.start() |
| log.Printf("router: hibernation active (bucket %s, prefix %q, idle %s) — flagged instances wake on demand", |
| hc.Bucket, hc.Prefix, hc.Idle) |