mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 06:04:55 -04:00
Distributed mode has not dialled a message broker since the control plane moved onto the workers' own outward tunnels and every fan-out family moved onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and the code that existed only to feed it. Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys, nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen indirect requires that only the NATS testcontainer pulled in. go.sum carries no nats line either, so the removal is not the partial kind where the require goes and the checksum stays. Deleted with them: pkg/natsauth in full, the broker client's remaining options and TLS files, the per-node JWT minting on both the register and the approve path, and the natsauth.Config parameter threaded through the node routes. The credential manager is renamed and stripped rather than deleted, because it still holds the tunnel token that every re-registration rotates. The bus flags stay accepted and ignored, and are now hidden, on every command that had them, so an existing unit file, compose file or Helm values file still starts on the day of the upgrade. What is not kept is the validation that REQUIRED one: a distributed frontend started with no bus URL is no longer fatal. The TLS paths lose type:"existingfile" deliberately, so a certificate deleted along with the broker cannot fail a startup. One operator-visible behaviour change: --nats-require-auth no longer makes an agent worker wait through admin approval. Ask for that wait with --distributed-require-auth, which already implied it. It is documented in the migration section and pinned from both sides. A deployment now needs PostgreSQL and the frontends' own HTTP listener, and nothing else. coverage-baseline.txt moves from 54.2 to 62.0. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
832 lines
38 KiB
Go
832 lines
38 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/tests/e2e/distributed/cluster"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// End-to-end proof that phase 3's control plane works under real processes.
|
|
//
|
|
// Phase 3 moved fourteen control verbs off NATS and onto the tunnel the worker
|
|
// already dials, made absence a database fact rather than a bus timeout, and
|
|
// took the backend worker off the bus entirely. Every one of those changes is
|
|
// proven by unit and integration specs only; these are the first that run the
|
|
// binaries an operator runs and let the real transports fail.
|
|
//
|
|
// Read the LAST spec before trusting the others. Frontend and worker share a
|
|
// host here, so almost anything that looks like it went over the tunnel could
|
|
// have gone some other way. The last one takes the tunnel away and requires the
|
|
// control plane to become unreachable, with the refusal naming the missing
|
|
// route rather than claiming the worker is absent; without it the rest could
|
|
// pass with the tunnel doing nothing.
|
|
|
|
const (
|
|
// controlRPCTimeout bounds one admin control call that has to cross a
|
|
// relay and reach a worker. Generous because the install verb behind it
|
|
// copies an artifact and starts a process.
|
|
controlRPCTimeout = 3 * time.Minute
|
|
|
|
// installJobTimeout bounds one node-scoped backend install from accepted to
|
|
// terminal. The gated spec spends most of it deliberately blocked.
|
|
installJobTimeout = "150s"
|
|
installJobPoll = "250ms"
|
|
|
|
// gateHoldWindow is how long a spec requires an install to stay
|
|
// unfinished while its gallery fetch is blocked.
|
|
//
|
|
// It is not a tolerance. The worker is stopped inside an HTTP read that
|
|
// only this spec can complete, so a terminal reply arriving inside this
|
|
// window is a reply written before the work it reports on, which is the
|
|
// exact defect the envelope ordering exists to prevent.
|
|
gateHoldWindow = "3s"
|
|
gateHoldPoll = "250ms"
|
|
|
|
// departedTimeout bounds the wait for a worker whose tunnel is gone past
|
|
// the grace to stop being reported healthy. It has to clear the grace the
|
|
// spec sets plus one health-monitor tick (15s by default).
|
|
departedTimeout = "90s"
|
|
departedPoll = "1s"
|
|
|
|
// churnGrace is the reconnect grace the churn spec runs with: long enough
|
|
// that the whole scenario, including the worker's capped 30s reconnect
|
|
// backoff and the re-home after it, happens INSIDE the window. The claim
|
|
// under test is "nothing is reaped inside the grace", so the window has to
|
|
// be the thing that is generous, not the assertion.
|
|
churnGrace = 10 * time.Minute
|
|
|
|
// churnHoldWindow is how long the churn spec requires the fleet to survive
|
|
// with its tunnel genuinely gone. It starts only after the tunnel has been
|
|
// OBSERVED unowned, and it is past the health monitor's 15s tick, so at
|
|
// least one sweep runs against a worker nothing can reach and finds nothing
|
|
// to reap.
|
|
churnHoldWindow = "20s"
|
|
churnHoldPoll = "1s"
|
|
|
|
// churnDropTimeout bounds the wait for a killed replica's claim to stop
|
|
// reading as a live owner.
|
|
//
|
|
// It has to outlast cluster.InstanceLiveness (30s): the ownership query
|
|
// joins against instances the DATABASE still considers live, so for the
|
|
// half minute after a SIGKILL a dead replica is still reported as holding
|
|
// every tunnel it held. That window is the reason this spec waits for the
|
|
// drop instead of assuming the kill produced one, and it is what an earlier
|
|
// version of this spec got wrong: its hold window sat entirely inside the
|
|
// liveness window, so it never reached the branch it claims to test and
|
|
// stayed green with the reconnect grace set to a nanosecond.
|
|
churnDropTimeout = "90s"
|
|
churnDropPoll = "1s"
|
|
|
|
// probeBackend is the backend a control spec installs on a worker.
|
|
//
|
|
// A name nothing else knows, and that is the point: the frontends' own
|
|
// backends directory is empty and the worker's holds only mock-backend, so
|
|
// a node backend listing that names this one can only have come from the
|
|
// worker, and only after an install that reached it.
|
|
probeBackend = "relay-probe"
|
|
|
|
// probeGalleryName is the gallery the probe backend is served from.
|
|
probeGalleryName = "e2e-control"
|
|
)
|
|
|
|
// nodeModel is the subset of a /api/nodes/:id/models row these specs assert on.
|
|
type nodeModel struct {
|
|
ModelName string `json:"model_name"`
|
|
State string `json:"state"`
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
// nodeBackend is the subset of a /api/nodes/:id/backends row these specs assert
|
|
// on. It is the worker's own answer to the backend.list control verb, relayed
|
|
// back through whichever replica took the request.
|
|
type nodeBackend struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// installJob is the subset of galleryop.OpStatus these specs assert on.
|
|
//
|
|
// It is read from GET /backends/jobs/:uuid and not from the UI's
|
|
// /api/backends/job/:uid, because only the former carries the per-node
|
|
// breakdown, and the per-node breakdown is where a worker's progress line
|
|
// lands. The UI endpoint drops it.
|
|
type installJob struct {
|
|
Processed bool `json:"processed"`
|
|
Message string `json:"message"`
|
|
Error string `json:"error"`
|
|
Nodes []installJobNode `json:"nodes"`
|
|
}
|
|
|
|
type installJobNode struct {
|
|
NodeID string `json:"node_id"`
|
|
Status string `json:"status"`
|
|
Phase string `json:"phase"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// nodeEntry returns the per-node row for nodeID, or nil.
|
|
func (j installJob) nodeEntry(nodeID string) *installJobNode {
|
|
for i := range j.Nodes {
|
|
if j.Nodes[i].NodeID == nodeID {
|
|
return &j.Nodes[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controlSession is one admin session for a whole cluster, with a budget long
|
|
// enough for a control RPC that crosses a relay.
|
|
//
|
|
// One per spec and never one per frontend, for the reason inferenceClient gives:
|
|
// the auth routes share a five-per-minute-per-IP budget and every request here
|
|
// comes from 127.0.0.1.
|
|
func controlSession(c *cluster.Cluster) *http.Client {
|
|
GinkgoHelper()
|
|
client, err := c.AdminSession(0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
client.Timeout = controlRPCTimeout
|
|
return client
|
|
}
|
|
|
|
// nodeBackendNames lists the backends a worker reports installed, asked at one
|
|
// specific frontend.
|
|
//
|
|
// The frontend answers this by issuing the backend.list control verb over that
|
|
// worker's tunnel, relayed through the owner when this replica is not it, so
|
|
// the returned names are the WORKER's and not this process's.
|
|
func nodeBackendNames(c *cluster.Cluster, client *http.Client, frontend int, nodeID string) ([]string, error) {
|
|
var listed []nodeBackend
|
|
if err := c.GetJSON(client, frontend, "/api/nodes/"+nodeID+"/backends", &listed); err != nil {
|
|
return nil, err
|
|
}
|
|
names := []string{}
|
|
for _, b := range listed {
|
|
names = append(names, b.Name)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// nodeModelNames lists the models a frontend records as loaded on a worker.
|
|
func nodeModelNames(c *cluster.Cluster, client *http.Client, frontend int, nodeID string) ([]string, error) {
|
|
var rows []nodeModel
|
|
if err := c.GetJSON(client, frontend, "/api/nodes/"+nodeID+"/models", &rows); err != nil {
|
|
return nil, err
|
|
}
|
|
names := []string{}
|
|
for _, m := range rows {
|
|
names = append(names, m.ModelName)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// gatedGallery serves one backend gallery index, and can hold the request open
|
|
// until a spec lets it go.
|
|
//
|
|
// The gate is what makes the ordering assertion deterministic rather than
|
|
// tolerant. A worker install that finishes in milliseconds gives a poller no
|
|
// window to observe progress in, and a spec that "usually" sees a tick before
|
|
// the reply is a spec that passes for timing reasons. Here the worker is
|
|
// stopped inside the gallery fetch, which happens AFTER it emits its first
|
|
// progress line and BEFORE it can produce any reply, so the two are separated
|
|
// by something the spec controls instead of by luck.
|
|
//
|
|
// fetches counts what the worker actually asked for, so a spec can prove the
|
|
// gate is on the path it thinks it is rather than assuming it.
|
|
type gatedGallery struct {
|
|
server *httptest.Server
|
|
index string
|
|
open chan struct{}
|
|
once sync.Once
|
|
fetches atomic.Int64
|
|
}
|
|
|
|
// newGatedGallery starts a gallery serving index. When gated, the first and
|
|
// every subsequent fetch blocks until release is called.
|
|
func newGatedGallery(index string, gated bool) *gatedGallery {
|
|
GinkgoHelper()
|
|
g := &gatedGallery{index: index, open: make(chan struct{})}
|
|
if !gated {
|
|
g.release()
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
|
|
g.fetches.Add(1)
|
|
select {
|
|
case <-g.open:
|
|
case <-r.Context().Done():
|
|
// The worker gave up, or the spec ended. Answering nothing is
|
|
// right: writing a body here would let a spec that failed its own
|
|
// gate assertion still see a successful install.
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
_, _ = io.WriteString(w, g.index)
|
|
})
|
|
g.server = httptest.NewServer(mux)
|
|
DeferCleanup(func() {
|
|
// Released before close so a handler still parked on the gate returns
|
|
// instead of leaking until the test binary exits.
|
|
g.release()
|
|
g.server.Close()
|
|
})
|
|
return g
|
|
}
|
|
|
|
func (g *gatedGallery) release() { g.once.Do(func() { close(g.open) }) }
|
|
|
|
// URL is the index URL a worker fetches.
|
|
func (g *gatedGallery) URL() string { return g.server.URL + "/index.yaml" }
|
|
|
|
// galleriesJSON is the backend_galleries override an install request carries.
|
|
// It is a JSON string INSIDE the request body, which is the shape
|
|
// InstallBackendOnNodeEndpoint binds.
|
|
func (g *gatedGallery) galleriesJSON() string {
|
|
return fmt.Sprintf(`[{"name":%q,"url":%q}]`, probeGalleryName, g.URL())
|
|
}
|
|
|
|
// probeBackendSource lays out a directory a worker can install as a backend and
|
|
// then RUN, and returns its path.
|
|
//
|
|
// It is a real backend by the two rules core/gallery enforces on a directory
|
|
// URI: a run.sh, which is the validation gate, and an executable named after
|
|
// the backend, which is what the worker's findBackend resolves and starts. The
|
|
// executable is the mock backend, so the install ends with a live gRPC process
|
|
// rather than with a process that dies and turns a relay spec into a spec about
|
|
// a broken artifact.
|
|
func probeBackendSource() string {
|
|
GinkgoHelper()
|
|
dir := filepath.Join(GinkgoT().TempDir(), probeBackend)
|
|
Expect(os.MkdirAll(dir, 0o755)).To(Succeed())
|
|
|
|
binary, err := os.ReadFile(mockBackendBinary())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(os.WriteFile(filepath.Join(dir, probeBackend), binary, 0o755)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(dir, "run.sh"),
|
|
[]byte("#!/bin/sh\nexec \"$(dirname \"$0\")/"+probeBackend+"\" \"$@\"\n"), 0o755)).To(Succeed())
|
|
return dir
|
|
}
|
|
|
|
// probeGalleryIndex is the one-entry gallery index that points at src.
|
|
func probeGalleryIndex(src string) string {
|
|
return fmt.Sprintf("- name: %s\n uri: %s\n description: e2e control-plane probe\n", probeBackend, src)
|
|
}
|
|
|
|
// startNodeInstall posts a node-scoped backend install at one frontend and
|
|
// returns the job id it was given.
|
|
//
|
|
// The install is asynchronous by design (202 plus a job id), so this is where
|
|
// the request stops and the job polling below takes over.
|
|
func startNodeInstall(c *cluster.Cluster, client *http.Client, frontend int, nodeID, backend, galleriesJSON string) string {
|
|
GinkgoHelper()
|
|
var accepted struct {
|
|
JobID string `json:"jobID"`
|
|
}
|
|
status, err := c.PostJSON(client, frontend, "/api/nodes/"+nodeID+"/backends/install", map[string]string{
|
|
"backend": backend,
|
|
"backend_galleries": galleriesJSON,
|
|
}, &accepted)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(status).To(Equal(http.StatusAccepted),
|
|
"frontend %d refused the install outright, so nothing was ever sent to the worker", frontend)
|
|
Expect(accepted.JobID).ToNot(BeEmpty())
|
|
return accepted.JobID
|
|
}
|
|
|
|
// jobProbe polls one install job at one frontend and keeps what it last saw, so
|
|
// a failing Eventually can name it.
|
|
type jobProbe struct {
|
|
cluster *cluster.Cluster
|
|
client *http.Client
|
|
frontend int
|
|
jobID string
|
|
nodeID string
|
|
|
|
lastErr error
|
|
last installJob
|
|
}
|
|
|
|
func newJobProbe(c *cluster.Cluster, client *http.Client, frontend int, jobID, nodeID string) *jobProbe {
|
|
return &jobProbe{cluster: c, client: client, frontend: frontend, jobID: jobID, nodeID: nodeID}
|
|
}
|
|
|
|
// read fetches the job once, keeping the error rather than raising it.
|
|
func (p *jobProbe) read() installJob {
|
|
var job installJob
|
|
if err := p.cluster.GetJSON(p.client, p.frontend, "/backends/jobs/"+p.jobID, &job); err != nil {
|
|
p.lastErr = err
|
|
return installJob{}
|
|
}
|
|
p.lastErr = nil
|
|
p.last = job
|
|
return job
|
|
}
|
|
|
|
// nodePhase is the phase the worker last reported for this node, or "" when the
|
|
// job carries no row for it yet.
|
|
func (p *jobProbe) nodePhase() string {
|
|
job := p.read()
|
|
if entry := job.nodeEntry(p.nodeID); entry != nil {
|
|
return entry.Phase
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nodeStatus is the status the job last recorded for this node.
|
|
func (p *jobProbe) nodeStatus() string {
|
|
job := p.read()
|
|
if entry := job.nodeEntry(p.nodeID); entry != nil {
|
|
return entry.Status
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// processed reports whether the job has reached a terminal state.
|
|
func (p *jobProbe) processed() bool { return p.read().Processed }
|
|
|
|
// jobError is the job's error text, empty when it has none.
|
|
func (p *jobProbe) jobError() string { return p.read().Error }
|
|
|
|
// explain builds a lazy failure description.
|
|
//
|
|
// Gomega formats a (string, args...) description when the assertion is
|
|
// CONSTRUCTED, which for an Eventually or a Consistently is before anything has
|
|
// gone wrong: the job it quoted would be the one from before the wait. A
|
|
// func() string is called only on failure. The same trap, and the same fix, as
|
|
// rosterProbe.explain.
|
|
func (p *jobProbe) explain(format string, args ...any) func() string {
|
|
return func() string {
|
|
return fmt.Sprintf(format, args...) + ": " + p.describe()
|
|
}
|
|
}
|
|
|
|
func (p *jobProbe) describe() string {
|
|
if p.lastErr != nil {
|
|
return fmt.Sprintf("frontend %d: the last read of job %s failed: %v", p.frontend, p.jobID, p.lastErr)
|
|
}
|
|
return fmt.Sprintf("frontend %d: job %s last read as %+v", p.frontend, p.jobID, p.last)
|
|
}
|
|
|
|
// withReconnectGrace pins how long a lost worker tunnel is read as reconnecting
|
|
// rather than gone.
|
|
func withReconnectGrace(d time.Duration) func(*cluster.Options) {
|
|
return func(o *cluster.Options) { o.ReconnectGrace = d }
|
|
}
|
|
|
|
// withAgentWorkers adds agent workers, the second worker kind, to a cluster.
|
|
// They hold a tunnel and are reached through it and through nothing else.
|
|
func withAgentWorkers(n int) func(*cluster.Options) {
|
|
return func(o *cluster.Options) { o.AgentWorkers = n }
|
|
}
|
|
|
|
var _ = Describe("Control plane over the worker tunnel", Label("Distributed"), Label("Cluster"), func() {
|
|
|
|
// Scenario 1, the headline. A backend worker that is connected to no bus at
|
|
// all registers, is scheduled onto, and serves inference.
|
|
//
|
|
// A wrong implementation leaves the worker up and inert, because nothing
|
|
// reaches it: before phase 3 every control verb travelled on NATS, so a
|
|
// worker with no NATS URL would register and heartbeat and never be given a
|
|
// backend or a model.
|
|
It("registers, schedules onto and serves a worker that is connected to no bus", func() {
|
|
c, dsn := startClusterOnFreshDB(1, 1, withMockModel("busless-model"))
|
|
client := inferenceClient(c)
|
|
|
|
// The environment of the RUNNING process, not the one the harness
|
|
// assembled. LOCALAI_REGISTER_TO is asserted alongside so an absent
|
|
// LOCALAI_NATS_URL is a fact about the worker rather than a read that
|
|
// returned nothing: a broken read would lose both.
|
|
environ, err := c.WorkerEnviron(0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(environ).To(ContainElement(HavePrefix("LOCALAI_REGISTER_TO=")),
|
|
"the worker's environment could not be read, so the absence below proves nothing")
|
|
for _, entry := range environ {
|
|
Expect(entry).ToNot(HavePrefix("LOCALAI_NATS_URL="),
|
|
"the worker was handed a bus URL, so this spec is not about a worker that has none")
|
|
}
|
|
// And the harness DOES still hand a LOCALAI_NATS_URL to the other
|
|
// processes in this cluster, so the worker's lack of one is a property
|
|
// of the worker and not of a harness that stopped setting the variable
|
|
// at all. Read from the FRONTEND's running process for the same reason
|
|
// the worker's absence is: the harness options would only say what was
|
|
// assembled. There is no broker behind that URL, which is the point:
|
|
// nothing dials it, so nothing notices.
|
|
frontendEnviron, err := c.FrontendEnviron(0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(frontendEnviron).To(ContainElement(HavePrefix("LOCALAI_NATS_URL=")),
|
|
"the harness stopped handing a bus URL to anything, so the worker's lack of one proves nothing")
|
|
|
|
probe := newRosterProbe(c, client, 0)
|
|
Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(ContainElement(c.WorkerName(0)), probe.describe)
|
|
nodeID := probe.idOf(c.WorkerName(0))
|
|
Expect(nodeID).ToNot(BeEmpty())
|
|
|
|
// Its tunnel is held here, and it advertises nothing. Both matter: the
|
|
// first says there is a route, the second says there is no other one.
|
|
owners := newTunnelOwners(openClusterDB(dsn))
|
|
Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
|
|
Should(Equal(0), owners.describe)
|
|
advertised, carriedKeys := probe.advertisementOf(c.WorkerName(0))
|
|
Expect(carriedKeys).To(BeTrue(),
|
|
"the roster payload no longer carries the advertisement keys, so this spec cannot tell a worker that advertises nothing from one it cannot read")
|
|
Expect(advertised).To(BeEmpty(),
|
|
"the worker advertised %q, so a frontend could have reached it without the tunnel", advertised)
|
|
|
|
// The inference is what drives the whole control plane: the frontend
|
|
// installs the backend on the worker, stages the model artifact to it
|
|
// and loads the model, all over the tunnel and all with no bus on the
|
|
// worker's side.
|
|
expectMockedInference(client, c.FrontendURL(0), "busless-model",
|
|
"a worker with no bus connection must still be scheduled onto and serve inference")
|
|
|
|
// And the frontend recorded the model as loaded THERE, naming the
|
|
// process the worker started. An empty address would mean the install
|
|
// reply named none, which is refused now rather than substituted.
|
|
var rows []nodeModel
|
|
Expect(c.GetJSON(client, 0, "/api/nodes/"+nodeID+"/models", &rows)).To(Succeed())
|
|
Expect(rows).ToNot(BeEmpty(), "no model is recorded on the worker that just served the request")
|
|
Expect(rows[0].ModelName).To(Equal("busless-model"))
|
|
Expect(rows[0].State).To(Equal("loaded"))
|
|
Expect(rows[0].Address).ToNot(BeEmpty(),
|
|
"the row names no backend process on the worker, so nothing could address it again")
|
|
})
|
|
|
|
// Scenario 2, the relay, and scenario 3, the ordering, in one cluster.
|
|
//
|
|
// They are one spec because a relay spec needs something to ask a worker
|
|
// that only the worker can answer, and a fresh worker's backend list is
|
|
// empty: the install is what puts something there. Splitting them would
|
|
// have cost a second cluster to assert less.
|
|
//
|
|
// A wrong implementation answers the control RPC by reaching the worker
|
|
// from the replica that took it, which works on one host and nowhere else,
|
|
// or refuses it as a worker that is not connected. A wrong STREAM
|
|
// implementation writes the terminal reply before the progress it reports
|
|
// on, or drops the progress entirely; both pass every unit spec.
|
|
It("installs a backend, streams its progress in order, and lists it back, all through the replica that does not own the worker", func() {
|
|
c, dsn := startClusterOnFreshDB(2, 1)
|
|
client := controlSession(c)
|
|
|
|
probe := newRosterProbe(c, client, 0)
|
|
Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(ContainElement(c.WorkerName(0)), probe.describe)
|
|
nodeID := probe.idOf(c.WorkerName(0))
|
|
Expect(nodeID).ToNot(BeEmpty())
|
|
|
|
// Which replica owns the tunnel is READ through the production Owner
|
|
// query, not assumed. The harness sends the worker to frontend 0 by
|
|
// default, and a spec that wrote that down would keep passing after the
|
|
// default changed while quietly testing the owner path instead.
|
|
owners := newTunnelOwners(openClusterDB(dsn))
|
|
var owner int
|
|
Eventually(func() int {
|
|
owner = owners.ownerIndexOf(c, 2, nodeID)
|
|
return owner
|
|
}, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
|
|
|
|
nonOwner := 1 - owner
|
|
Expect(owners.ownerIndexOf(c, 2, nodeID)).ToNot(Equal(nonOwner),
|
|
"frontend %d owns the worker's tunnel, so a request to it would not be relayed and this spec would prove nothing", nonOwner)
|
|
|
|
// Nothing is installed on the worker yet, and the request below is the
|
|
// only thing that could change that.
|
|
Expect(nodeBackendNames(c, client, nonOwner, nodeID)).ToNot(ContainElement(probeBackend),
|
|
"the worker already has %q, so a listing that names it later says nothing about this install", probeBackend)
|
|
|
|
gallery := newGatedGallery(probeGalleryIndex(probeBackendSource()), true)
|
|
jobID := startNodeInstall(c, client, nonOwner, nodeID, probeBackend, gallery.galleriesJSON())
|
|
job := newJobProbe(c, client, nonOwner, jobID, nodeID)
|
|
|
|
// The worker has emitted its first progress line and is now stopped
|
|
// inside the gallery fetch. Seeing the phase here proves a progress
|
|
// envelope crossed the relay and was decoded, and it proves it BEFORE
|
|
// any reply could exist, because the worker cannot produce one until
|
|
// this spec releases the gate.
|
|
Eventually(job.nodePhase, installJobTimeout, installJobPoll).Should(Equal("resolving"), job.describe)
|
|
Eventually(gallery.fetches.Load, installJobTimeout, installJobPoll).Should(BeNumerically(">", 0),
|
|
"the worker never fetched the gated gallery, so the gate is not on the path this spec thinks it is")
|
|
|
|
// Held, not sampled. A reply arriving in this window is a reply written
|
|
// ahead of the work it reports on.
|
|
Consistently(job.processed, gateHoldWindow, gateHoldPoll).Should(BeFalse(),
|
|
job.explain("the install reported a terminal result while the worker was still blocked fetching its gallery"))
|
|
|
|
gallery.release()
|
|
|
|
Eventually(job.processed, installJobTimeout, installJobPoll).Should(BeTrue(), job.describe)
|
|
Expect(job.jobError()).To(BeEmpty(), "the relayed install failed: %s", job.describe())
|
|
Expect(job.nodeStatus()).To(Equal("success"), job.describe)
|
|
|
|
// Nothing follows the terminal reply. A worker that appended a late
|
|
// progress line after its reply would move this row back to
|
|
// "downloading"; the guard against that is ndjsonStream.done, and this
|
|
// is the only place it is exercised over a real stream.
|
|
Consistently(job.nodeStatus, gateHoldWindow, gateHoldPoll).Should(Equal("success"),
|
|
job.explain("the node's status moved after the install's terminal reply"))
|
|
|
|
// A SECOND control verb across the relay, and the one whose answer
|
|
// could only have come from the worker: the frontends' own backends
|
|
// directories are empty, and this backend was installed on the worker
|
|
// alone.
|
|
Eventually(func() ([]string, error) { return nodeBackendNames(c, client, nonOwner, nodeID) },
|
|
installJobTimeout, installJobPoll).Should(ContainElement(probeBackend))
|
|
|
|
// And the frontend that answered does not have it itself, so it cannot
|
|
// have been reporting its own installation as the worker's.
|
|
//
|
|
// Read off the filesystem and not from GET /backends: in distributed
|
|
// mode that endpoint reports the CLUSTER's backends, so it names this
|
|
// one whether the frontend has it or not, and an assertion on it fails
|
|
// for a reason that has nothing to do with what is being proven.
|
|
backendsDir, err := c.FrontendBackendsDir(nonOwner)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
entries, err := os.ReadDir(backendsDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, e := range entries {
|
|
Expect(e.Name()).ToNot(Equal(probeBackend),
|
|
"frontend %d installed %q locally, so its answer about the worker may be about itself", nonOwner, probeBackend)
|
|
}
|
|
|
|
// THIS is what makes every request above a relayed one. It rules out
|
|
// the two ways they could have succeeded without a relay: a replica
|
|
// that took the tunnel for itself, and the tunnel moving to nonOwner
|
|
// mid-spec so that it served directly. Both leave the owner changed.
|
|
Expect(owners.ownerIndexOf(c, 2, nodeID)).To(Equal(owner),
|
|
"the tunnel is no longer held by frontend %d, so the requests to frontend %d were not necessarily relayed", owner, nonOwner)
|
|
})
|
|
|
|
// Scenario 4, absence under replica churn. The catastrophe control.
|
|
//
|
|
// A worker whose tunnel drops and returns INSIDE the grace must not be
|
|
// reaped. A wrong implementation reads a lost tunnel as a departed worker
|
|
// and evicts the models it is serving in the seconds before the re-home,
|
|
// which is the fleet-wide outage the four-valued presence answer exists to
|
|
// prevent.
|
|
It("reaps nothing when the replica holding a worker's tunnel dies and the worker re-homes inside the grace", func() {
|
|
var balancer *frontendBalancer
|
|
c, dsn := startClusterOnFreshDB(2, 1, withMockModel("churn-model"),
|
|
withBalancer(&balancer), withReconnectGrace(churnGrace))
|
|
|
|
client := inferenceClient(c)
|
|
probe := newRosterProbe(c, client, 0)
|
|
Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(ContainElement(c.WorkerName(0)), probe.describe)
|
|
nodeID := probe.idOf(c.WorkerName(0))
|
|
Expect(nodeID).ToNot(BeEmpty())
|
|
|
|
owners := newTunnelOwners(openClusterDB(dsn))
|
|
var owner int
|
|
Eventually(func() int {
|
|
owner = owners.ownerIndexOf(c, 2, nodeID)
|
|
return owner
|
|
}, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
|
|
survivor := 1 - owner
|
|
|
|
// A model actually loaded on the worker, or there is nothing to reap
|
|
// and the assertions below hold vacuously.
|
|
expectMockedInference(client, c.FrontendURL(owner), "churn-model",
|
|
"inference must work before the owner is killed, or nothing here is at risk")
|
|
Expect(nodeModelNames(c, client, survivor, nodeID)).To(ContainElement("churn-model"))
|
|
|
|
// The tunnel is taken away BEFORE the owner is killed, and held away
|
|
// until the assertions below have run.
|
|
//
|
|
// Without the block the worker re-homes onto the survivor within
|
|
// seconds, and a spec that asserted over those seconds would never
|
|
// reach the state it is about: for the first half minute after a
|
|
// SIGKILL the dead replica still reads as a live owner, so presence is
|
|
// "connected" and no rule about a departed worker has anything to act
|
|
// on. Blocking makes the outage last long enough for a departure to be
|
|
// recorded, which is the only way the grace is consulted at all.
|
|
balancer.blockTunnel.Store(true)
|
|
Expect(c.KillFrontend(owner)).To(Succeed())
|
|
Eventually(func() bool { return c.FrontendAlive(owner) }, "20s", "500ms").Should(BeFalse())
|
|
|
|
// Read at the SURVIVOR from here on: the probe above is bound to a dead
|
|
// process.
|
|
atSurvivor := newRosterProbe(c, client, survivor)
|
|
|
|
// Wait for the drop to be REAL rather than assume the kill produced
|
|
// one. Until this reads empty the ownership query still reports the
|
|
// dead replica, and everything below would be asserting about a worker
|
|
// the deployment believes is connected.
|
|
Eventually(func() string { return owners.ownerOf(nodeID) }, churnDropTimeout, churnDropPoll).
|
|
Should(BeEmpty(), owners.describe)
|
|
|
|
// THIS is the window the phase is about: the tunnel is gone, the
|
|
// departure is recorded, and the grace has not run out. Nothing may be
|
|
// reaped in it.
|
|
Consistently(func() []string {
|
|
names, err := nodeModelNames(c, client, survivor, nodeID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return names
|
|
}, churnHoldWindow, churnHoldPoll).Should(ContainElement("churn-model"),
|
|
"the model loaded on the worker was reaped while its tunnel was inside the reconnect grace")
|
|
Consistently(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, churnHoldWindow, churnHoldPoll).
|
|
ShouldNot(Equal("unhealthy"),
|
|
atSurvivor.explain("the worker was demoted while its tunnel was inside the reconnect grace"))
|
|
|
|
// And it comes back: with the block lifted the tunnel re-homes onto the
|
|
// survivor and the same model serves again. Without this the assertions
|
|
// above would be satisfied by a fleet that was simply never touched.
|
|
balancer.blockTunnel.Store(false)
|
|
Eventually(func() int { return owners.ownerIndexOf(c, 2, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
|
|
Should(Equal(survivor), owners.describe)
|
|
Eventually(atSurvivor.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(ContainElement(c.WorkerName(0)), atSurvivor.describe)
|
|
Expect(atSurvivor.idOf(c.WorkerName(0))).To(Equal(nodeID),
|
|
"the worker re-registered rather than re-homing, so this proves nothing about a tunnel surviving its replica")
|
|
|
|
eventuallyMockedInference(client, c.FrontendURL(survivor), "churn-model",
|
|
"the survivor must serve the re-homed worker")
|
|
})
|
|
|
|
// Scenario 5, the wedge task 6 fixed, now for BOTH worker types.
|
|
//
|
|
// A worker with a FRESH heartbeat and a tunnel that is gone past the grace
|
|
// must stop being reported healthy. Before task 6 it stayed healthy
|
|
// forever, with every request for a model on it failing "no route", because
|
|
// every reaper keyed on the heartbeat and the heartbeat was fine.
|
|
//
|
|
// The agent worker in the same cluster used to be the control for the other
|
|
// direction: it was skipped by name, because its real work travelled on the
|
|
// message bus and a departed tunnel said nothing about it. There is no bus.
|
|
// The balancer below blocks its tunnel dial exactly as it blocks the backend
|
|
// worker's, its departure ages past the same grace, and it is now reachable
|
|
// through that tunnel and through nothing else, so it must be demoted for
|
|
// the same reason and on the same evidence.
|
|
It("stops reporting a heartbeating worker healthy once its tunnel is gone past the grace, for agent workers too", func() {
|
|
var balancer *frontendBalancer
|
|
c, dsn := startClusterOnFreshDB(2, 1, withBalancer(&balancer),
|
|
withAgentWorkers(1), withReconnectGrace(10*time.Second))
|
|
|
|
client := controlSession(c)
|
|
probe := newRosterProbe(c, client, 0)
|
|
Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(And(ContainElement(c.WorkerName(0)), ContainElement(c.AgentWorkerName(0))), probe.describe)
|
|
nodeID := probe.idOf(c.WorkerName(0))
|
|
Expect(nodeID).ToNot(BeEmpty())
|
|
agentID := probe.idOf(c.AgentWorkerName(0))
|
|
Expect(agentID).ToNot(BeEmpty())
|
|
|
|
owners := newTunnelOwners(openClusterDB(dsn))
|
|
var owner int
|
|
Eventually(func() int {
|
|
owner = owners.ownerIndexOf(c, 2, nodeID)
|
|
return owner
|
|
}, tunnelOwnershipTimeout, tunnelOwnershipPoll).Should(BeNumerically(">=", 0), owners.describe)
|
|
survivor := 1 - owner
|
|
|
|
// The precondition the agent half of this spec rests on, asserted and
|
|
// not assumed. The balancer forwards to the FIRST target that accepts a
|
|
// connection, so both workers land on the same replica and killing it
|
|
// takes both tunnels; if that ever changes, the agent worker's tunnel
|
|
// survives on the replica this spec keeps, its departure never ages,
|
|
// and the assertion below would be waiting for a demotion that is
|
|
// correctly not coming.
|
|
Eventually(func() int { return owners.ownerIndexOf(c, 2, agentID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
|
|
Should(Equal(owner),
|
|
"the agent worker's tunnel is not on the replica this spec kills, so its departure would never age and the agent assertion below would prove nothing")
|
|
|
|
// Take the tunnel away permanently: block the dial, then kill the
|
|
// replica holding the live session. The worker keeps registering and
|
|
// heartbeating through the balancer, which still proxies everything
|
|
// except the tunnel connect.
|
|
balancer.blockTunnel.Store(true)
|
|
Expect(c.KillFrontend(owner)).To(Succeed())
|
|
Eventually(func() bool { return c.FrontendAlive(owner) }, "20s", "500ms").Should(BeFalse())
|
|
|
|
atSurvivor := newRosterProbe(c, client, survivor)
|
|
|
|
// The worker really is trying and really is being refused, so what
|
|
// makes it unreachable below is the tunnel and not the worker dying.
|
|
before := balancer.tunnelDials.Load()
|
|
Eventually(balancer.tunnelDials.Load, "90s", "500ms").Should(BeNumerically(">", before),
|
|
"the worker stopped dialling its tunnel, so blocking the dial is not what keeps it away")
|
|
|
|
// The verdict. Fresh heartbeat, no tunnel, past the grace.
|
|
Eventually(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, departedTimeout, departedPoll).
|
|
Should(Equal("unhealthy"), atSurvivor.describe)
|
|
|
|
// The heartbeat was NOT what demoted it. Without this the assertion
|
|
// above is satisfied by a worker that simply died, which says nothing
|
|
// about tunnel departure.
|
|
Expect(atSurvivor.heartbeatOf(c.WorkerName(0))).To(BeTemporally(">", time.Now().Add(-1*time.Minute)),
|
|
"the worker's heartbeat is stale, so it was demoted for being gone rather than for having no route")
|
|
|
|
// The agent worker, in the same cluster, under the same grace, on the
|
|
// same health monitor. Its tunnel is blocked by the same balancer, so
|
|
// its departure really has outlived the grace, and the skip that used
|
|
// to exempt it by node type is gone: an agent worker is reached through
|
|
// its tunnel and through nothing else now, so a departed tunnel is the
|
|
// only symptom an unreachable one has.
|
|
Eventually(func() string { return atSurvivor.statusOf(c.AgentWorkerName(0)) }, departedTimeout, departedPoll).
|
|
Should(Equal("unhealthy"),
|
|
atSurvivor.explain("an agent worker whose tunnel is gone past the grace is still reported healthy, so nothing in the deployment can tell that it is unreachable"))
|
|
|
|
// And it was the ROUTE, for the agent worker as much as for the backend
|
|
// one. Without this the assertion above is satisfied by an agent worker
|
|
// that simply died.
|
|
Expect(atSurvivor.heartbeatOf(c.AgentWorkerName(0))).To(BeTemporally(">", time.Now().Add(-1*time.Minute)),
|
|
"the agent worker's heartbeat is stale, so it was demoted for being gone rather than for having no route")
|
|
|
|
// And the demotion reverses when the route comes back, so it is a
|
|
// statement about the route rather than a one-way condemnation.
|
|
balancer.blockTunnel.Store(false)
|
|
Eventually(func() int { return owners.ownerIndexOf(c, 2, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
|
|
Should(Equal(survivor), owners.describe)
|
|
Eventually(func() string { return atSurvivor.statusOf(c.WorkerName(0)) }, departedTimeout, departedPoll).
|
|
Should(Equal("healthy"), atSurvivor.describe)
|
|
})
|
|
|
|
// Scenario 6. THE NEGATIVE CONTROL FOR THE WHOLE SUITE.
|
|
//
|
|
// Frontend and worker share a host, so every address the control plane
|
|
// names is one the frontend could also have reached directly. If it did,
|
|
// every spec above would pass with the tunnel doing nothing. This one takes
|
|
// the tunnel away and requires the control plane to become unreachable,
|
|
// with the refusal naming the ROUTE, while registration, heartbeats and the
|
|
// roster stay exactly as they were.
|
|
//
|
|
// It cannot use LOCALAI_WORKER_TUNNEL=false: that is a fatal startup error
|
|
// after phase 2, and a worker that never started says nothing about a
|
|
// worker reachable by some other path.
|
|
It("cannot drive the control plane on a worker whose tunnel is refused, reaps nothing for it, and can as soon as it is not", func() {
|
|
var balancer *frontendBalancer
|
|
c, dsn := startClusterOnFreshDB(1, 1,
|
|
withBalancer(&balancer, func(b *frontendBalancer) { b.blockTunnel.Store(true) }))
|
|
|
|
client := controlSession(c)
|
|
probe := newRosterProbe(c, client, 0)
|
|
Eventually(probe.healthyNames, nodeRosterTimeout, nodeRosterPoll).
|
|
Should(ContainElement(c.WorkerName(0)), probe.describe)
|
|
nodeID := probe.idOf(c.WorkerName(0))
|
|
Expect(nodeID).ToNot(BeEmpty())
|
|
|
|
// In every respect the worker the specs above used, except the tunnel,
|
|
// and both halves of that are asserted rather than assumed: it tried,
|
|
// and no replica holds it.
|
|
Eventually(balancer.tunnelDials.Load, "60s", "500ms").Should(BeNumerically(">", 0),
|
|
"the worker never dialled its tunnel, so blocking the dial is not what makes it unreachable below")
|
|
owners := newTunnelOwners(openClusterDB(dsn))
|
|
Consistently(func() string { return owners.ownerOf(nodeID) }, "5s", "500ms").Should(BeEmpty(),
|
|
"a replica holds this worker's tunnel, so the blocker is not blocking")
|
|
|
|
gallery := newGatedGallery(probeGalleryIndex(probeBackendSource()), false)
|
|
jobID := startNodeInstall(c, client, 0, nodeID, probeBackend, gallery.galleriesJSON())
|
|
job := newJobProbe(c, client, 0, jobID, nodeID)
|
|
|
|
Eventually(job.processed, installJobTimeout, installJobPoll).Should(BeTrue(), job.describe)
|
|
|
|
// It fails, and it fails for the ROUTING reason. A refusal for any
|
|
// other cause (a gallery it could not read, a node it thought absent, a
|
|
// backend that would not install) would satisfy "it failed" just as
|
|
// well and would leave every spec above unproven.
|
|
//
|
|
// One substring and not a disjunction: "no route" is what
|
|
// cluster.ErrNoRoute reads as, and nothing else on this path produces
|
|
// it. In particular it is NOT what an absent worker produces, and that
|
|
// distinction is the phase's whole absence contract: a worker this
|
|
// replica cannot reach must never be reported as one that has gone.
|
|
Expect(job.jobError()).To(ContainSubstring("no route"),
|
|
"the refusal does not name the missing route, so this spec cannot tell a worker with no tunnel from an ordinary install failure: %s", job.describe())
|
|
|
|
// And nothing was reaped for it. The worker never stopped
|
|
// heartbeating, so a control RPC that could not be delivered must not
|
|
// have cost it its row or its health.
|
|
_, listErr := nodeBackendNames(c, client, 0, nodeID)
|
|
Expect(listErr).To(HaveOccurred(),
|
|
"the frontend answered a backend listing for a worker it has no route to, so something other than the tunnel reaches it")
|
|
Consistently(probe.healthyNames, "10s", "1s").
|
|
Should(ContainElement(c.WorkerName(0)),
|
|
probe.explain("the worker was demoted or removed because a control RPC could not be routed to it"))
|
|
|
|
// The control's own control: put the tunnel back, change nothing else,
|
|
// and the SAME install must now succeed. Without this the failure above
|
|
// could be any of the ordinary reasons an e2e install fails.
|
|
balancer.blockTunnel.Store(false)
|
|
Eventually(func() int { return owners.ownerIndexOf(c, 1, nodeID) }, tunnelOwnershipTimeout, tunnelOwnershipPoll).
|
|
Should(Equal(0), owners.describe)
|
|
|
|
retryID := startNodeInstall(c, client, 0, nodeID, probeBackend, gallery.galleriesJSON())
|
|
retry := newJobProbe(c, client, 0, retryID, nodeID)
|
|
Eventually(retry.processed, installJobTimeout, installJobPoll).Should(BeTrue(), retry.describe)
|
|
Expect(retry.jobError()).To(BeEmpty(),
|
|
"the only thing that changed is the tunnel, so the failure above was the missing tunnel and nothing else: %s", retry.describe())
|
|
Eventually(func() ([]string, error) { return nodeBackendNames(c, client, 0, nodeID) },
|
|
installJobTimeout, installJobPoll).Should(ContainElement(probeBackend))
|
|
})
|
|
})
|