mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
8ab96b29a1
* fix: replicate every JetStream KV bucket across the cluster A KV bucket created without a replica count gets one replica, and one replica lives on one JetStream-chosen server recorded in no config. Losing, restarting or partitioning that server makes the bucket return "no responders available" from every node at once, so a single-node event becomes a cluster-wide outage. The reconciler leader-lease buckets were the worst case: acquiring a lease is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed. The cluster's node count is now declared once, in config.LoadConfig, and read by the one helper every bucket creation goes through. Every process that creates a bucket loads the cluster config first, so no service can forget to pass it and no signature has to carry it. An undeclared size is an error at creation rather than a silent fall back to one replica. Existing buckets are raised rather than left behind: a service opening one raises it, the daemon sweeps them all after formation, and "spx admin kv replicas --repair" does it on demand. Raising reads the live stream config and changes only the replica count, and never lowers one. Removes the ~30 replicas/clusterSize parameters that only forwarded a number, and JetStreamManager.UpdateReplicas, which set len(nodes) with no clamp and would have failed outright on a cluster of six or more. Proved by a repo-wide invariant test pinning bucket creation to the one helper, a whole-cluster sweep in every multi-node nightly cell, and a new kvquorum suite (cell 33) that takes away the node leading the most buckets and requires the survivors to keep answering for all of them. * chore: name cell 33 in the nightly dispatch input * fix: a KV bucket that cannot be raised must still open The first version of the replica fix treated a raise it could not place as a hard error, and on a real three-node cluster that turned the fix into the outage it prevents. A node whose config named a fourth server wanted four replicas, NATS answered "no suitable peers for placement" for three, and the daemon retried for its full five-minute budget without finishing bootstrap. The bucket was present, quorate and serving on three nodes throughout. A config naming a node before that node serves is the ordinary middle of growing a cluster, so that is a state to tolerate rather than refuse. The line is now drawn between the two cases instead of around both. Creating a bucket below the cluster's replica count still fails, because that is the defect and a fallback there reintroduces it where it is hardest to notice. Failing to raise an existing bucket warns and hands the bucket back, and the raise is retried on every open besides the daemon sweep and --repair. This matters most in kvlease, which runs on every reconcile tick: refusing there would stop every reconciler sharing a lease bucket that was working. getOrCreateBucket now attaches before it creates. A create asking for more replicas than the cluster can place is refused before the server looks at the name, so with create-first "this bucket already exists" was an inference from which error came back, and the wrong one comes back on a single-node server. RaiseAllBucketReplicas attempts every bucket and joins the failures, and --repair keeps going too. Stopping at the first would leave the rest of a cluster short because one bucket could not be placed. Also adds nats.ServerName/NodeFromServerName beside the template that renders server_name, with a test tying them together. The kvquorum suite had mapped stream peers to nodes by comparing them to node names, which they are not: they are the node name behind a "spinifex-nats-" prefix, so nothing matched and the node-loss test could not choose a victim. * test: wait for the right NATS peer count after a node rejoins /routez reports a node's routes to its peers, not its own membership, so a fully formed three-node cluster is two peers per node. The kvquorum suite waited for three and spent its whole five-minute rejoin budget on a cluster that had already reformed. Every other caller passes len(Nodes)-1; the helper's doc comment now says so. The assertion the test exists for had already passed: node1 was leading 18 of 47 buckets when it was taken away, and node2 and node3 each still answered for all 47. * fix: replicate KV buckets across the largest odd count a cluster can place A bucket was replicated across min(nodes, 5) servers, which stores a fourth full copy on a four-node cluster and still survives exactly one loss — the same tolerance R3 gives for three quarters of the write amplification. A Raft majority needs an odd count, so the rule becomes the largest odd count the cluster can place, capped at five: 1, 3, 3, 5, 5. It is one expression with two entry points, so no caller can hold a stale copy of the policy and keep passing after the policy changes. Three retry loops could not tell a configuration fault from a cluster that had yet to form. An undeclared cluster size never becomes declared by waiting, so each of them spent its whole budget on one and then reported quorum as the cause. They now stop on a permanent error and say what is actually wrong. This is what the integration suite was hitting: it builds its gateway by hand and never loads a cluster config, so the size was never declared and the IAM retry sat in its sleep until the test timed out. Both ends are fixed. The audit reported configuration alone. A bucket at three replicas with one peer offline and one lagging is one failure from unavailable and read as ok. BucketReport now carries the leader and a count of the peers JetStream itself calls current and reachable, quorum is measured against that count, and spx admin kv replicas exits 0, 1 or 2 so a deployment gate can tell a problem --repair fixes from one needing a human. Declaration is write-once: daemon.Start refuses a conflicting redeclaration because it is about to create buckets, and it declares before JetStream init rather than after it. config.LoadConfig warns instead, because it also reads config files that are not this process's own cluster. spx admin init --nodes 2 is refused — no replica count on two nodes both survives a loss and accepts writes — and single-node replication is asserted in the single e2e suite, so it runs in every nightly host permutation. * fix: withhold the vLLM serving image until its artifact is published The catalog offers ubuntu-26.04-vllm-serving-x86_64, and the qcow2 it names 404s on iso.mulgadc.com, so spx admin images import would fail for every operator who tried it. Its GPU siblings are published; this one never was. The entry moves to WithheldImages rather than being deleted, because the entry is the definition — the URL, the checksum and the two tags resolveServingAMI filters on. Publishing the artifact is then a move back, not a retype. Bedrock already handles the image not being registered: resolveServingAMI returns ErrServingAMINotFound, which is the honest answer while there is nothing to launch. The bedrock tag contract is still pinned, now against the map the definition lives in, and it additionally asserts the entry is not offered for import. The e2e reachability walk covers AvailableImages alone, so a withheld entry is out of it by construction. * fix: drop the cluster size from the call sites dev added Two constructors on dev still pass a replica count that no longer exists as a parameter: the cluster size is a declared process fact, so EKSServiceDeps has no ClusterSize field and NewIAMServiceImpl takes three arguments. The merge is textually clean and breaks the build, which is the shape of collision a signature removal always has. * docs: explain what an operator sees when a cluster server fails The replication section said how many copies a bucket has and stopped there, which leaves the question an operator actually has during an incident: one server is gone, what still works. It now covers the majority side carrying on, an isolated server refusing changes on purpose and why that is the behaviour you want, guests continuing to run while anything that has to be recorded does not, the risk of reading state from a server that is cut off, and a returning server catching up by itself. The surrounding prose reads from the operator's side rather than the implementation's: what you see and what to do, not which consensus protocol is underneath.
197 lines
6.8 KiB
Go
197 lines
6.8 KiB
Go
//go:build e2e
|
|
|
|
package harness
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mulgadc/spinifex/spinifex/clustersize"
|
|
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
|
"github.com/mulgadc/spinifex/spinifex/utils"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// KVReplicaFactors connects to the cluster NATS and returns the configured
|
|
// replica factor for each named KV bucket, read from JetStream stream metadata
|
|
// (the "KV_" stream prefix is applied internally). The replica count lives in
|
|
// cluster-global Raft metadata, so any node's NATS reports the same value.
|
|
// Fatals if NATS is unreachable or a bucket's stream cannot be introspected.
|
|
func KVReplicaFactors(t *testing.T, env *Env, buckets ...string) map[string]int {
|
|
t.Helper()
|
|
host, token, ca := natsConn(t, env)
|
|
nc, err := utils.ConnectNATS(host, token, ca)
|
|
if err != nil {
|
|
t.Fatalf("connect NATS %s: %v", host, err)
|
|
}
|
|
defer nc.Close()
|
|
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
t.Fatalf("jetstream context: %v", err)
|
|
}
|
|
|
|
out := make(map[string]int, len(buckets))
|
|
for _, b := range buckets {
|
|
stream, err := js.Stream(t.Context(), "KV_"+b)
|
|
if err != nil {
|
|
t.Fatalf("open stream for KV bucket %q: %v", b, err)
|
|
}
|
|
info, err := stream.Info(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("stream info for KV bucket %q: %v", b, err)
|
|
}
|
|
out[b] = info.Config.Replicas
|
|
}
|
|
return out
|
|
}
|
|
|
|
// WantKVReplicas is how many nodes a KV bucket must be replicated across on a
|
|
// cluster of this size.
|
|
//
|
|
// It defers to the production rule rather than restating it. A test that carried
|
|
// its own copy would keep passing after the policy changed, which is the one
|
|
// thing this assertion must not do.
|
|
func WantKVReplicas(nodes int) int {
|
|
return clustersize.ReplicasFor(nodes)
|
|
}
|
|
|
|
// DialClusterNATS opens a NATS connection to the cluster, using the caller's
|
|
// env for the address and credentials. The connection is closed on cleanup.
|
|
func DialClusterNATS(t *testing.T, env *Env) *nats.Conn {
|
|
t.Helper()
|
|
host, token, ca := natsConn(t, env)
|
|
nc, err := utils.ConnectNATS(host, token, ca)
|
|
if err != nil {
|
|
t.Fatalf("connect NATS %s: %v", host, err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
return nc
|
|
}
|
|
|
|
// KVReplication reports every KV bucket in the cluster: its replica count, what
|
|
// the cluster's size entitles it to, and which nodes hold it.
|
|
//
|
|
// The node count is declared here because this process never loaded a cluster
|
|
// config, and the audit refuses to guess one rather than assuming a single
|
|
// replica. Declaring is write-once, so the reset makes a second call with the
|
|
// same count safe and a third with a different one a visible failure.
|
|
func KVReplication(t *testing.T, env *Env, nodes int) []kvutil.BucketReport {
|
|
t.Helper()
|
|
clustersize.ResetForTest()
|
|
if err := clustersize.Declare(nodes); err != nil {
|
|
t.Fatalf("declare cluster size %d: %v", nodes, err)
|
|
}
|
|
js, err := jetstream.New(DialClusterNATS(t, env))
|
|
if err != nil {
|
|
t.Fatalf("jetstream context: %v", err)
|
|
}
|
|
reports, err := kvutil.AuditBucketReplicas(t.Context(), js)
|
|
if err != nil {
|
|
t.Fatalf("audit KV bucket replicas: %v", err)
|
|
}
|
|
return reports
|
|
}
|
|
|
|
// RequireKVQuorum fails unless every KV bucket in the cluster is replicated
|
|
// across every node, and unless each one is actually held by that many distinct
|
|
// nodes rather than merely configured for it.
|
|
//
|
|
// A bucket below the node count lives on fewer nodes than depend on it, so
|
|
// losing one of them makes it unreadable and unwritable from every node at
|
|
// once — a cluster-wide outage caused by a single-node event. This runs in
|
|
// every multi-node suite rather than one of them, because a bucket created by
|
|
// any service on any cell is the one that can be wrong.
|
|
func RequireKVQuorum(t *testing.T, env *Env, nodes int) {
|
|
t.Helper()
|
|
want := WantKVReplicas(nodes)
|
|
|
|
reports := KVReplication(t, env, nodes)
|
|
if len(reports) == 0 {
|
|
t.Fatal("the cluster reports no KV buckets at all, so nothing was checked")
|
|
}
|
|
|
|
var problems []string
|
|
for _, r := range reports {
|
|
if r.Replicas < want {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s is on %d replicas, want %d: losing the node holding it takes it down cluster-wide",
|
|
r.Bucket, r.Replicas, want))
|
|
continue
|
|
}
|
|
// Configured, placed and serving are three facts. A stream can carry the
|
|
// right count while its group is short of peers or those peers are not
|
|
// caught up, and either reads as healthy right up until the node it is
|
|
// really on goes away.
|
|
if held := distinctPeers(r.Peers); held < want {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s is configured for %d replicas but held by %d node(s) (%s)",
|
|
r.Bucket, r.Replicas, held, strings.Join(r.Peers, ",")))
|
|
continue
|
|
}
|
|
if r.Online < want {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s is held by %d node(s) but only %d are caught up and reachable (leader %s)",
|
|
r.Bucket, len(r.Peers), r.Online, r.Leader))
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
sort.Strings(problems)
|
|
t.Fatalf("%d of %d KV buckets are not replicated across the cluster's %d nodes:\n %s",
|
|
len(problems), len(reports), nodes, strings.Join(problems, "\n "))
|
|
}
|
|
Detail(t, "kv_buckets", len(reports), "replicas", want, "nodes", nodes)
|
|
}
|
|
|
|
// RequireKVSingleNode fails unless a one-node cluster's buckets are exactly what
|
|
// one node can give them: one replica each, all of them serving.
|
|
//
|
|
// It is the counterpart assertion to RequireKVQuorum, and it is not the same
|
|
// check with a smaller number. On one node R1 is correct rather than a defect, so
|
|
// what has to be proved is the opposite: that nothing declared a count this
|
|
// deployment cannot place, and that a bucket which cannot be raised has not been
|
|
// left unreadable by the attempt.
|
|
func RequireKVSingleNode(t *testing.T, env *Env) {
|
|
t.Helper()
|
|
|
|
reports := KVReplication(t, env, 1)
|
|
if len(reports) == 0 {
|
|
t.Fatal("the cluster reports no KV buckets at all, so nothing was checked")
|
|
}
|
|
|
|
var problems []string
|
|
for _, r := range reports {
|
|
if r.Replicas != 1 {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s is configured for %d replicas on a single node, which JetStream cannot place",
|
|
r.Bucket, r.Replicas))
|
|
continue
|
|
}
|
|
if !r.HasQuorum() {
|
|
problems = append(problems, fmt.Sprintf("%s is not serving: %d of %d replicas online",
|
|
r.Bucket, r.Online, r.Replicas))
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
sort.Strings(problems)
|
|
t.Fatalf("%d of %d KV buckets are wrong for a single-node cluster:\n %s",
|
|
len(problems), len(reports), strings.Join(problems, "\n "))
|
|
}
|
|
Detail(t, "kv_buckets", len(reports), "replicas", 1, "nodes", 1)
|
|
}
|
|
|
|
// distinctPeers counts the unique node names holding a stream, since the leader
|
|
// is reported separately from the followers and could repeat.
|
|
func distinctPeers(peers []string) int {
|
|
seen := make(map[string]struct{}, len(peers))
|
|
for _, p := range peers {
|
|
if p != "" {
|
|
seen[p] = struct{}{}
|
|
}
|
|
}
|
|
return len(seen)
|
|
}
|