mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 17:44:30 -04:00
Review found the recurring class: assertions that a wrong implementation also satisfies. The "refuse promptly, never park the peer" guarantee was stated in three places and tested in none. Removing the Close from the no-relay branch left the whole cluster suite green, because the specs asserted only that some error arrived and yamux reports a read deadline as ErrTimeout: a parked stream satisfied that as well as a refused one. Both specs now require an ENDING, EOF or a reset, inside a deadline short enough that parking is unmistakable, and both go red when the Close is removed. Deregistration existed only in a comment. Membership.Stop ended the loop and left the row behind, so every clean rolling restart had peers dialling a corpse for the full liveness window; the shutdown comment described the opposite. Registry.Deregister deletes the row and the connections that replica owned, in one transaction, for the reason the sweeper does both, and an e2e spec pins departure inside a budget shorter than the liveness window so it cannot pass on the sweeper doing the work. Before: the spec times out with both replicas still live. After: 3.6s. The configured advertised address bypassed every check discovery makes, so the one value most likely to be copied between hosts, 127.0.0.1, was taken verbatim and would make every peer dial itself. Both paths now share one rejection rule: unparseable is refused, "this host" is warned about once and honoured, because a single-host deployment uses it correctly. Two comments claimed more than the code does. The sweeper said a stalled replica recovers via re-register; only its instance row does, while the connections another replica reaped stay gone and the sockets stay held here - phase 2 must re-claim, on re-register, every connection a replica still holds locally. And Owner became OwnerRow, documenting that the owner it names may be dead for up to InstanceLiveness plus a heartbeat and that any caller acting on it must join instances itself, so the deferred constraint lives at the call site rather than in a report; the plain name is left free for the joining version. Minors: warn once when the peer link mounts with no registration token, so an operator sees the cause rather than 401s; Stop no longer blocks forever when Start was never called; corrected the NewRegistry migration doc and an e2e comment that described a 6s window as "throughout". Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
162 lines
5.7 KiB
Go
162 lines
5.7 KiB
Go
package cluster_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/testutil"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var _ = Describe("Instance registry", func() {
|
|
var (
|
|
db *gorm.DB
|
|
reg *cluster.Registry
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
db = testutil.SetupTestDB()
|
|
ctx = context.Background()
|
|
Expect(cluster.Migrate(ctx, db)).To(Succeed())
|
|
reg = cluster.NewRegistry(db)
|
|
})
|
|
|
|
It("registers an instance and reads it back", func() {
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
|
|
|
|
got, err := reg.Get(ctx, "inst-a")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.AdvertisedAddr).To(Equal("10.0.0.1:8080"))
|
|
Expect(got.Version).To(Equal("v1"))
|
|
})
|
|
|
|
It("re-registering the same id updates the address instead of duplicating", func() {
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1")).To(Succeed())
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.9:9090", "v2")).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Hour)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(HaveLen(1))
|
|
Expect(live[0].AdvertisedAddr).To(Equal("10.0.0.9:9090"))
|
|
})
|
|
|
|
It("reports a missing instance distinguishably", func() {
|
|
_, err := reg.Get(ctx, "nope")
|
|
Expect(err).To(MatchError(cluster.ErrInstanceNotFound))
|
|
})
|
|
|
|
It("excludes instances whose heartbeat has aged out", func() {
|
|
Expect(reg.Register(ctx, "stale", "10.0.0.1:8080", "v1")).To(Succeed())
|
|
// Age the row directly; sleeping in a spec is forbidden.
|
|
Expect(db.Model(&cluster.Instance{}).Where("id = ?", "stale").
|
|
Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Minute)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(BeEmpty())
|
|
})
|
|
|
|
It("brings a stale instance back with a heartbeat", func() {
|
|
Expect(reg.Register(ctx, "revive", "10.0.0.1:8080", "v1")).To(Succeed())
|
|
Expect(db.Model(&cluster.Instance{}).Where("id = ?", "revive").
|
|
Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
|
|
Expect(reg.Heartbeat(ctx, "revive")).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Minute)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(HaveLen(1))
|
|
})
|
|
|
|
It("heartbeating an unknown instance is an error, not a silent insert", func() {
|
|
Expect(reg.Heartbeat(ctx, "ghost")).To(MatchError(cluster.ErrInstanceNotFound))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Advertised address discovery", func() {
|
|
// The address itself depends on host networking and is deliberately not
|
|
// asserted. What is portable is the shape: whatever interface routes to the
|
|
// database, the port must be the one the caller asked for, not the
|
|
// database's.
|
|
It("combines a local interface with the caller's port", func() {
|
|
addr, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 8080)
|
|
if err != nil {
|
|
Skip("no route to a database host on this machine: " + err.Error())
|
|
}
|
|
host, port, splitErr := net.SplitHostPort(addr)
|
|
Expect(splitErr).ToNot(HaveOccurred())
|
|
Expect(port).To(Equal("8080"))
|
|
Expect(net.ParseIP(host)).ToNot(BeNil())
|
|
})
|
|
|
|
It("refuses a DSN it cannot derive an address from", func() {
|
|
_, err := cluster.DiscoverAdvertisedAddr("", 8080)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
// A database on this same host routes over loopback on every platform, so
|
|
// this is deterministic rather than host-dependent. Returning 127.0.0.1
|
|
// would make a peer dialling this replica reach itself.
|
|
It("refuses a loopback route instead of advertising an address peers cannot use", func() {
|
|
addr, err := cluster.DiscoverAdvertisedAddr("postgres://user@127.0.0.1:5432/testdb", 8080)
|
|
Expect(addr).To(BeEmpty())
|
|
Expect(err).To(MatchError(ContainSubstring("loopback")))
|
|
})
|
|
|
|
It("refuses a port that cannot be dialled", func() {
|
|
_, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 0)
|
|
Expect(err).To(MatchError(ContainSubstring("out of range")))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Checking a configured advertised address", func() {
|
|
// The configured address bypasses discovery entirely, so it bypasses every
|
|
// rejection discovery makes. These are the checks that put back the ones
|
|
// that can be made without a route to look at.
|
|
It("accepts an address on a network other hosts can reach", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("10.0.0.7:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(BeEmpty())
|
|
})
|
|
|
|
It("accepts a name, because the dialler is what resolves it", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("localai-frontend.default.svc:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(BeEmpty())
|
|
})
|
|
|
|
It("refuses an address with no port, which nothing could dial", func() {
|
|
_, err := cluster.CheckAdvertisedAddr("10.0.0.7")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("refuses a port outside the dialable range", func() {
|
|
_, err := cluster.CheckAdvertisedAddr("10.0.0.7:0")
|
|
Expect(err).To(MatchError(ContainSubstring("port")))
|
|
})
|
|
|
|
It("refuses an address that names no host", func() {
|
|
_, err := cluster.CheckAdvertisedAddr(":8080")
|
|
Expect(err).To(MatchError(ContainSubstring("no host")))
|
|
})
|
|
|
|
It("reports loopback without refusing it, because one host is a supported topology", func() {
|
|
// Correct on a single host, and the value most likely to be copied
|
|
// onto three, where every peer would then dial itself.
|
|
reason, err := cluster.CheckAdvertisedAddr("127.0.0.1:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(ContainSubstring("loopback"))
|
|
})
|
|
|
|
It("reports a bind address, which is not an address at all", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("0.0.0.0:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(ContainSubstring("unspecified"))
|
|
})
|
|
})
|