mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-23 06:34:53 -04:00
Re-review round 2. One blocking item, and it was a spec I wrote: eight goroutines raced at the probe cache and nothing made them coalesce, so a straggler that missed the flight re-entered the probe and double-closed a channel. It panicked about one run in three and took the four-suite race block down. The green verification I reported was not reproducible, which means one green run was never evidence for a spec that coordinates goroutines. Its comment claimed the probe blocked until every goroutine was inside flight.Do, and that gap was exactly the panic: the comment described the design intended rather than the one written. It is deterministic now rather than tolerant. singleflight.DoChan registers its channel on an in-flight call under the group's own mutex and returns without running its function, so calling it while the leader is provably parked inside the probe joins that exact flight with no window and no dependence on the scheduler. The spec asserts the join really happened, that the joiner got the reason and not only the answer, and that the probe ran once; the entered channel is sent on rather than closed so a second probe fails an assertion instead of panicking. Twenty runs green under race against the committed code, five out of five red on the mutation back to a closed-over variable. The future-decorator gap is closed in the lint gate, but not the way the review suggested, and the reason is worth recording. HasMethod rejects inline signatures outright, its method-reference form needs a package ruleguard's own typechecker can import and that typechecker cannot import this module, and Implements tests the value method set while every Unwrap is on a pointer receiver, so it fired on all three wrappers that already had one. So the safe shape is structural instead. grpc.WrappedBackend gives the same pass-through method set plus Unwrap on a value receiver, and a decorator that embeds it is transparent by construction; forgetting stops being expressible rather than merely discouraged, which is the move loopbackService already makes in the worker. FileStagingClient and ConnectionEvictingClient embed it and their hand-written Unwrap methods are gone. The ruleguard rule then only has to catch the raw embedding, needs no type filter, and cannot misfire. It was verified to fire on a throwaway wrapper and stay silent on a correct one, and reports nothing across core and pkg with the baseline disabled. InFlightTrackingClient is the one exception and says why in a nolint: it embeds ControlBackend deliberately so that leaving an inference method unwrapped breaks the build, and WrappedBackend embeds the full interface, so adopting it would silently restore pass-through for every inference method and delete that guarantee. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
163 lines
6.1 KiB
Go
163 lines
6.1 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
grpc "github.com/mudler/LocalAI/pkg/grpc"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/xlog"
|
|
ggrpc "google.golang.org/grpc"
|
|
)
|
|
|
|
// ConnectionEvictingClient wraps a grpc.Backend. When any inference method
|
|
// fails with a connection error (server unreachable), it calls the evict
|
|
// callback to remove the model from the ModelLoader's cache. The error is
|
|
// still returned to the caller — the NEXT request will trigger rescheduling
|
|
// via SmartRouter.
|
|
type ConnectionEvictingClient struct {
|
|
grpc.WrappedBackend
|
|
modelID string
|
|
evict func()
|
|
once sync.Once
|
|
}
|
|
|
|
var _ grpc.BackendUnwrapper = (*ConnectionEvictingClient)(nil)
|
|
|
|
func newConnectionEvictingClient(inner grpc.Backend, modelID string, evict func()) grpc.Backend {
|
|
return &ConnectionEvictingClient{
|
|
WrappedBackend: grpc.WrappedBackend{Backend: inner},
|
|
modelID: modelID,
|
|
evict: evict,
|
|
}
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) checkErr(err error) {
|
|
if err == nil || !isConnectionError(err) {
|
|
return
|
|
}
|
|
// The fifth site of the same shape, and the one reached during INFERENCE
|
|
// rather than a health check. evict() runs ShutdownModel, which for a remote
|
|
// model sends backend.stop over NATS to every node holding it and deletes
|
|
// every replica row. In distributed mode the client underneath reaches the
|
|
// backend over the worker's tunnel, and a failure of THAT transport arrives
|
|
// as the same codes.Unavailable a dead backend produces; evicting on it
|
|
// stops a model that is loaded and serving, on a worker that is
|
|
// heartbeating. A locally spawned backend has no custom transport, so this
|
|
// reports nil and the behaviour there is exactly what it always was.
|
|
if dialErr := grpc.LastDialErrorOf(c.Backend); dialErr != nil {
|
|
xlog.Warn("Inference failed because the worker could not be reached; keeping the model",
|
|
"model", c.modelID, "error", dialErr)
|
|
return
|
|
}
|
|
c.once.Do(func() {
|
|
xlog.Warn("Connection error during inference, evicting model from cache",
|
|
"model", c.modelID, "error", err)
|
|
c.evict()
|
|
})
|
|
}
|
|
|
|
// --- Intercepted inference methods ---
|
|
|
|
func (c *ConnectionEvictingClient) Predict(ctx context.Context, in *pb.PredictOptions, opts ...ggrpc.CallOption) (*pb.Reply, error) {
|
|
reply, err := c.Backend.Predict(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return reply, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) PredictStream(ctx context.Context, in *pb.PredictOptions, f func(reply *pb.Reply), opts ...ggrpc.CallOption) error {
|
|
err := c.Backend.PredictStream(ctx, in, f, opts...)
|
|
c.checkErr(err)
|
|
return err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Embeddings(ctx context.Context, in *pb.PredictOptions, opts ...ggrpc.CallOption) (*pb.EmbeddingResult, error) {
|
|
result, err := c.Backend.Embeddings(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) GenerateImage(ctx context.Context, in *pb.GenerateImageRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.GenerateImage(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.GenerateVideo(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Animate3D(ctx context.Context, in *pb.Animate3DRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.Animate3D(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.Generate3D(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) TTS(ctx context.Context, in *pb.TTSRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.TTS(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) TTSStream(ctx context.Context, in *pb.TTSRequest, f func(reply *pb.Reply), opts ...ggrpc.CallOption) error {
|
|
err := c.Backend.TTSStream(ctx, in, f, opts...)
|
|
c.checkErr(err)
|
|
return err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) SoundGeneration(ctx context.Context, in *pb.SoundGenerationRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
|
|
result, err := c.Backend.SoundGeneration(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) AudioTranscription(ctx context.Context, in *pb.TranscriptRequest, opts ...ggrpc.CallOption) (*pb.TranscriptResult, error) {
|
|
result, err := c.Backend.AudioTranscription(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) AudioTranscriptionStream(ctx context.Context, in *pb.TranscriptRequest, f func(chunk *pb.TranscriptStreamResponse), opts ...ggrpc.CallOption) error {
|
|
err := c.Backend.AudioTranscriptionStream(ctx, in, f, opts...)
|
|
c.checkErr(err)
|
|
return err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Detect(ctx context.Context, in *pb.DetectOptions, opts ...ggrpc.CallOption) (*pb.DetectResponse, error) {
|
|
result, err := c.Backend.Detect(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Depth(ctx context.Context, in *pb.DepthRequest, opts ...ggrpc.CallOption) (*pb.DepthResponse, error) {
|
|
result, err := c.Backend.Depth(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Rerank(ctx context.Context, in *pb.RerankRequest, opts ...ggrpc.CallOption) (*pb.RerankResult, error) {
|
|
result, err := c.Backend.Rerank(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) TokenClassify(ctx context.Context, in *pb.TokenClassifyRequest, opts ...ggrpc.CallOption) (*pb.TokenClassifyResponse, error) {
|
|
result, err := c.Backend.TokenClassify(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|
|
|
|
func (c *ConnectionEvictingClient) Score(ctx context.Context, in *pb.ScoreRequest, opts ...ggrpc.CallOption) (*pb.ScoreResponse, error) {
|
|
result, err := c.Backend.Score(ctx, in, opts...)
|
|
c.checkErr(err)
|
|
return result, err
|
|
}
|