mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-01 02:24:34 -04:00
ReapStale deleted from instances then node_connections while Deregister took them the other way round, both inside one transaction and both running concurrently by design: a replica shuts down while a peer sweeps it. Opposite orders let each hold the row the other waits for. PostgreSQL breaks the cycle by aborting one side, so the cost today is a warning rather than lost data, but the inversion costs nothing to remove. Deregister now deletes the instance row first. That is the order ReapStale is forced into anyway, since its connection delete asks which instance rows survived, so the sweeper is the fixed side. Both functions say the order is deliberate and shared, and name the other. A spec records the statements each path issues and asserts they delete from the same two tables in the same order; racing two transactions until they really deadlock would be flaky and could pass for the wrong reason. The rest is comment and spec accuracy, deferred from the phase 1 task reviews: - co-location does not imply loopback. Compose's usual host=postgres resolves to a bridge address and discovery works there; it is a DSN that NAMES localhost that yields a loopback source address. Corrected in the DiscoverAdvertisedAddr doc and in the spec comment that repeated it. - unroutableReason labelled every scoped address "link-local", including the class the check exists for, and formatted the IP with %s, which drops the %iface, so the reported address was not the one being rejected. Split into two cases, both rendered with their zone. CheckAdvertisedAddr passed zone "" and net.ParseIP rejects fe80::1%eth0, so a scoped literal looked like a name and collected no warning at all; the zone is now split off before parsing. - Splice's "Both callers satisfy it" claimed callers that still do not exist. It now names the two stream types the wake-on-Close property was verified against and says a phase 2 caller over anything else has to check it. - restored, short, why a socket-level ECONNRESET stays reported while a yamux reset does not: the yamux endings are the teardown Splice's own Close provokes, and whether an aborted request is routine is the relay's policy. - the real-yamux spec's far.Read had no deadline, so a stall parked the suite rather than failing it. - gorilla's SetWriteDeadline is conn.go:796, not 787. - ClusterPathPrefix is no longer derived from: the peer route spells its path out, because core/services/cluster must not import core/http/auth. The comment now points at the spec that holds them together instead of claiming a derivation the move removed. - the epoch spec asserted e2 > e1, an ordering Claim's doc tells callers not to rely on. It asserts uniqueness, which is what the fence guarantees, and is named for that. A sibling spec still described the epoch as incrementing in SQL when it is drawn from a sequence. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
299 lines
12 KiB
Go
299 lines
12 KiB
Go
// Package cluster records the frontend replicas that make up one LocalAI
|
|
// deployment and, later, the links between them. It is deliberately free of
|
|
// dependencies on core/services/nodes: nodes migrates and consumes the models
|
|
// declared here, so an import in the other direction would be a cycle.
|
|
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// ErrInstanceNotFound reports that no row exists for the requested instance ID.
|
|
// Callers distinguish it from a transport failure to decide whether to
|
|
// re-register or to retry.
|
|
var ErrInstanceNotFound = errors.New("cluster: instance not found")
|
|
|
|
// Instance is one live frontend replica, keyed by the ID that replica chose for
|
|
// itself. Column sizes mirror nodes.BackendNode so both tables agree on what an
|
|
// ID and a host:port look like.
|
|
type Instance struct {
|
|
ID string `gorm:"primaryKey;size:36" json:"id"`
|
|
AdvertisedAddr string `gorm:"size:255" json:"advertised_addr"` // host:port other replicas dial
|
|
Version string `gorm:"size:64" json:"version"`
|
|
LastSeen time.Time `gorm:"index" json:"last_seen"`
|
|
}
|
|
|
|
// Registry reads and writes the instances table.
|
|
type Registry struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewRegistry returns a Registry over db. Migration is the caller's job: this
|
|
// package's tables and sequence are created by Migrate, which the nodes
|
|
// registry calls under the one advisory lock that covers every table in the
|
|
// deployment.
|
|
func NewRegistry(db *gorm.DB) *Registry {
|
|
return &Registry{db: db}
|
|
}
|
|
|
|
// Register records this replica's address, refreshing LastSeen. It upserts on
|
|
// the primary key rather than deleting and re-inserting, so a concurrent Live
|
|
// never observes a live replica as missing.
|
|
func (r *Registry) Register(ctx context.Context, id, addr, version string) error {
|
|
// last_seen is stamped by the database, never by this process. Liveness is
|
|
// compared across replicas, so it has to be measured on the one clock they
|
|
// all share; with per-replica clocks the effective Live window becomes
|
|
// `within - writerBehind - readerAhead`, which either evicts healthy peers
|
|
// or keeps dead ones alive.
|
|
if err := r.db.WithContext(ctx).Model(&Instance{}).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "id"}},
|
|
DoUpdates: clause.Assignments(map[string]any{
|
|
"advertised_addr": addr,
|
|
"version": version,
|
|
"last_seen": gorm.Expr("now()"),
|
|
}),
|
|
}).Create(map[string]any{
|
|
"id": id,
|
|
"advertised_addr": addr,
|
|
"version": version,
|
|
"last_seen": gorm.Expr("now()"),
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("registering instance %q: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Heartbeat refreshes LastSeen for an already-registered instance. An unknown
|
|
// ID is an error rather than an insert: a heartbeat carries no address, so
|
|
// inserting would publish a replica nobody can reach.
|
|
func (r *Registry) Heartbeat(ctx context.Context, id string) error {
|
|
// gorm reports no error when a Where matches nothing, so the miss has to be
|
|
// read off RowsAffected.
|
|
res := r.db.WithContext(ctx).Model(&Instance{}).
|
|
Where("id = ?", id).
|
|
Update("last_seen", gorm.Expr("now()"))
|
|
if res.Error != nil {
|
|
return fmt.Errorf("heartbeating instance %q: %w", id, res.Error)
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return fmt.Errorf("heartbeating instance %q: %w", id, ErrInstanceNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Live returns the instances whose LastSeen is newer than now-within.
|
|
func (r *Registry) Live(ctx context.Context, within time.Duration) ([]Instance, error) {
|
|
var out []Instance
|
|
// The cutoff is computed by the database for the same reason Register stamps
|
|
// there: a reader's clock must not decide whether another replica is alive.
|
|
if err := r.db.WithContext(ctx).
|
|
Where("last_seen > now() - make_interval(secs => ?)", within.Seconds()).
|
|
Order("id").
|
|
Find(&out).Error; err != nil {
|
|
return nil, fmt.Errorf("listing live instances: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Get returns one instance, or ErrInstanceNotFound if it is not registered.
|
|
func (r *Registry) Get(ctx context.Context, id string) (*Instance, error) {
|
|
var inst Instance
|
|
err := r.db.WithContext(ctx).Where("id = ?", id).First(&inst).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("getting instance %q: %w", id, ErrInstanceNotFound)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting instance %q: %w", id, err)
|
|
}
|
|
return &inst, nil
|
|
}
|
|
|
|
// DiscoverAdvertisedAddr determines the address this replica should advertise
|
|
// to its peers, with no operator configuration.
|
|
//
|
|
// Every replica in a deployment reaches the same PostgreSQL server, so the
|
|
// local interface that routes to PostgreSQL is on a network all the replicas
|
|
// demonstrably share. Opening a UDP socket toward the database sends no packet;
|
|
// it only asks the kernel to pick a source address for that route, which is the
|
|
// address to advertise. The caller supplies the port, since the frontend's
|
|
// listening port has nothing to do with the database's.
|
|
//
|
|
// What defeats the discovery is a DSN that NAMES loopback, not the database
|
|
// being co-located. Co-location is fine as long as the DSN names something
|
|
// routable: compose's usual `host=postgres` resolves to a bridge address, so
|
|
// the kernel picks this container's own bridge IP as the source, which is the
|
|
// address a peer on that network dials. It is `host=localhost` (or 127.0.0.1,
|
|
// or ::1) that makes the route loopback, and advertising 127.0.0.1 would make
|
|
// a peer dialling this replica reach itself instead. So an unspecified,
|
|
// loopback, or scoped source address is rejected with an error telling the
|
|
// operator to configure the advertised address explicitly, rather than
|
|
// returned. There is no fallback string: no address is better than a wrong one.
|
|
func DiscoverAdvertisedAddr(dsn string, port int) (string, error) {
|
|
// A port of 0 (or out of range) would produce an address nothing can dial,
|
|
// and the caller is likelier to have passed an unset field than to mean it.
|
|
if port < 1 || port > 65535 {
|
|
return "", fmt.Errorf("advertised port %d is out of range 1-65535", port)
|
|
}
|
|
host, dbPort, err := dsnHostPort(dsn)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
conn, err := net.Dial("udp", net.JoinHostPort(host, dbPort))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolving route to database host %q: %w", host, err)
|
|
}
|
|
// Nothing was ever sent on this socket, so a close failure carries no
|
|
// information about the address we just read.
|
|
defer func() { _ = conn.Close() }()
|
|
local, ok := conn.LocalAddr().(*net.UDPAddr)
|
|
if !ok || local.IP == nil {
|
|
return "", fmt.Errorf("no local address on the route to database host %q; set the advertised address explicitly", host)
|
|
}
|
|
if reason := unroutableReason(local.IP, local.Zone); reason != "" {
|
|
return "", fmt.Errorf("the route to database host %q is %s; set the advertised address explicitly", host, reason)
|
|
}
|
|
return net.JoinHostPort(local.IP.String(), strconv.Itoa(port)), nil
|
|
}
|
|
|
|
// unroutableReason says why ip cannot serve as an address other hosts dial, or
|
|
// "" when it can. It is the one place that decides, so the discovered address
|
|
// and the configured one are held to the same rule; they differ only in what
|
|
// they do with the answer.
|
|
func unroutableReason(ip net.IP, zone string) string {
|
|
switch {
|
|
case ip == nil || ip.IsUnspecified():
|
|
return fmt.Sprintf("unspecified (%s), which is a bind address rather than one anything can connect to", ip)
|
|
case ip.IsLoopback():
|
|
return fmt.Sprintf("loopback (%s), which means \"this host\" to whoever dials it, so every peer would reach itself", ip)
|
|
case ip.IsLinkLocalUnicast():
|
|
return fmt.Sprintf("link-local (%s), which peers on other hosts cannot dial", withZone(ip, zone))
|
|
// A zone is normally attached only to a link-local address, which the case
|
|
// above already rejects. This one stays for the scoped address of some
|
|
// other class a platform may hand back, and says so rather than repeating
|
|
// the link-local label: the two have different cures, and an operator told
|
|
// the wrong one looks in the wrong place.
|
|
case zone != "":
|
|
return fmt.Sprintf("scoped to interface %q (%s), and the zone is dropped by the time an address is stored, leaving a host nothing can dial", zone, withZone(ip, zone))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// withZone renders the address the way it has to be dialled. IP.String() drops
|
|
// the %iface, so an unadorned %s in a rejection reports an address that differs
|
|
// from the one being rejected.
|
|
func withZone(ip net.IP, zone string) string {
|
|
if zone == "" {
|
|
return ip.String()
|
|
}
|
|
return ip.String() + "%" + zone
|
|
}
|
|
|
|
// CheckAdvertisedAddr validates an address an operator configured, returning a
|
|
// reason it is questionable, or an error if it is unusable.
|
|
//
|
|
// A configured address bypasses every check DiscoverAdvertisedAddr performs,
|
|
// and the value most likely to be copied is the one that works on a single
|
|
// host: "127.0.0.1:8080" on three hosts makes every peer dial itself, which
|
|
// presents as a relay loop rather than as a configuration error.
|
|
//
|
|
// The split between error and reason is deliberate. An address that cannot be
|
|
// parsed into host and port is an error, because nothing can dial it at all. An
|
|
// address that merely means "this host" is a reason to warn and no more: a
|
|
// single-host deployment, including this repository's own e2e cluster, uses one
|
|
// correctly, and refusing it would be refusing a supported topology.
|
|
func CheckAdvertisedAddr(addr string) (reason string, err error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("advertised address %q is not host:port: %w", addr, err)
|
|
}
|
|
if host == "" {
|
|
return "", fmt.Errorf("advertised address %q names no host, so peers have nothing to dial", addr)
|
|
}
|
|
portNumber, err := strconv.Atoi(port)
|
|
if err != nil || portNumber < 1 || portNumber > 65535 {
|
|
return "", fmt.Errorf("advertised address %q has no usable port (want 1-65535)", addr)
|
|
}
|
|
// The zone is split off before parsing because net.ParseIP rejects
|
|
// "fe80::1%eth0" outright. Left joined, a scoped literal would look like a
|
|
// name and collect no warning at all, which is the one case where the
|
|
// address is guaranteed not to work for a peer.
|
|
host, zone := splitZone(host)
|
|
// A name is resolved by whoever dials it, and may resolve differently
|
|
// there, so its presence is all this side can check.
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return "", nil
|
|
}
|
|
return unroutableReason(ip, zone), nil
|
|
}
|
|
|
|
// splitZone separates an IPv6 scope from the address it qualifies. A name
|
|
// never carries one, so a host with no "%" comes back unchanged.
|
|
func splitZone(host string) (string, string) {
|
|
addr, zone, found := strings.Cut(host, "%")
|
|
if !found {
|
|
return host, ""
|
|
}
|
|
return addr, zone
|
|
}
|
|
|
|
// dsnHostPort extracts the host and port from either DSN form gorm's postgres
|
|
// driver accepts: a URL ("postgres://user:pass@host:5432/db") or libpq keyword
|
|
// pairs ("host=... port=...").
|
|
func dsnHostPort(dsn string) (string, string, error) {
|
|
const defaultPort = "5432"
|
|
dsn = strings.TrimSpace(dsn)
|
|
if dsn == "" {
|
|
return "", "", errors.New("empty database DSN")
|
|
}
|
|
|
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("parsing database DSN: %w", err)
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return "", "", errors.New("database DSN has no host")
|
|
}
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = defaultPort
|
|
}
|
|
return host, port, nil
|
|
}
|
|
|
|
host, port := "", defaultPort
|
|
for _, field := range strings.Fields(dsn) {
|
|
key, value, found := strings.Cut(field, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "host":
|
|
host = value
|
|
case "port":
|
|
port = value
|
|
}
|
|
}
|
|
if host == "" {
|
|
return "", "", errors.New("database DSN has no host")
|
|
}
|
|
// A Unix socket directory tells us nothing about which interface reaches
|
|
// the database, so there is no address to derive.
|
|
if strings.HasPrefix(host, "/") {
|
|
return "", "", fmt.Errorf("database DSN uses a unix socket (%q); no routable address to advertise", host)
|
|
}
|
|
return host, port, nil
|
|
}
|