Skip to content

Commit 458c44d

Browse files
committed
feat(distributed): add per-model ModelLoadInfo persistence
Adds a dedicated ModelLoadInfo table keyed by model name, decoupled from the per-replica NodeModel rows. The reconciler can now recover model load metadata after every NodeModel row has been removed (worker death, eviction, MarkOffline reaping, frontend restart with stale heartbeats), which is the read side of Bug-1 from the distributed mode bug hunt. Registry exposes: - UpsertModelLoadInfo: ON CONFLICT (model_name) update; last-write-wins, matching the existing per-replica blob semantics under concurrent multi-frontend dispatch. - GetModelLoadInfo: read from the new table first; fall back to the legacy NodeModel-blob scan for rows written before any frontend in the cluster ran an UpsertModelLoadInfo (rolling-upgrade transition). SetNodeModelLoadInfo (per-replica blob) is preserved for backward compatibility and per-replica diagnostics; the dispatch-path hook in the next commit calls both. The new table joins the existing nodes AutoMigrate set under the same schema-migration advisory lock. Refs: Bug-1, docs/superpowers/specs/2026-05-24-distributed-mode-bug-hunt-findings.md Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7[1m]
1 parent 06e777b commit 458c44d

4 files changed

Lines changed: 80 additions & 3 deletions

File tree

core/services/nodes/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type ModelRouter interface {
1717
TouchNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int)
1818
SetNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int, state, address string, initialInFlight int) error
1919
SetNodeModelLoadInfo(ctx context.Context, nodeID, modelName string, replicaIndex int, backendType string, optsBlob []byte) error
20+
UpsertModelLoadInfo(ctx context.Context, modelName, backendType string, optsBlob []byte) error
2021
GetModelLoadInfo(ctx context.Context, modelName string) (backendType string, optsBlob []byte, err error)
2122
NextFreeReplicaIndex(ctx context.Context, nodeID, modelName string, maxSlots int) (int, error)
2223
CountReplicasOnNode(ctx context.Context, nodeID, modelName string) (int, error)

core/services/nodes/model_router_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ func (f *fakeModelRouterForSmartRouter) SetNodeModel(_ context.Context, _, _ str
5656
func (f *fakeModelRouterForSmartRouter) SetNodeModelLoadInfo(_ context.Context, _, _ string, _ int, _ string, _ []byte) error {
5757
return nil
5858
}
59+
func (f *fakeModelRouterForSmartRouter) UpsertModelLoadInfo(_ context.Context, _, _ string, _ []byte) error {
60+
return nil
61+
}
5962
func (f *fakeModelRouterForSmartRouter) GetModelLoadInfo(_ context.Context, _ string) (string, []byte, error) {
6063
return "", nil, fmt.Errorf("not found")
6164
}

core/services/nodes/registry.go

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,31 @@ type NodeModel struct {
9494
UpdatedAt time.Time `json:"updated_at"`
9595
}
9696

97+
// ModelLoadInfo is per-model load metadata kept independently of NodeModel rows
98+
// so the Replica Reconciler can re-load a model after every replica row has
99+
// been removed (worker death, eviction, MarkOffline reaping, frontend restart
100+
// with stale heartbeats).
101+
//
102+
// Why a separate table when the same blob is also stamped on each NodeModel
103+
// row? NodeModel rows are tied to a live (node, replica) slot and get deleted
104+
// when a backend stops being healthy. Tying the only copy of load info to
105+
// that lifecycle is exactly what caused Bug-1: a frontend restart followed by
106+
// transient worker-row removal left no copy of ModelOptions, so the reconciler
107+
// could not bring `min_replicas` back without a fresh inference request.
108+
//
109+
// Keyed by ModelName (the tracking key used by the router); last-write-wins
110+
// on the opts blob because two concurrent frontends dispatching the same
111+
// model with slightly different opts converge on whichever finished last.
112+
// That is identical to the per-NodeModel-row semantics today; if a stronger
113+
// guarantee is needed in the future, the row carries UpdatedAt for ordering.
114+
type ModelLoadInfo struct {
115+
ModelName string `gorm:"primaryKey;size:255" json:"model_name"`
116+
BackendType string `gorm:"size:128" json:"backend_type"`
117+
ModelOptsBlob []byte `gorm:"type:bytea" json:"-"`
118+
CreatedAt time.Time `json:"created_at"`
119+
UpdatedAt time.Time `json:"updated_at"`
120+
}
121+
97122
// NodeLabel is a key-value label on a node (like K8s labels).
98123
type NodeLabel struct {
99124
ID string `gorm:"primaryKey;size:36" json:"id"`
@@ -178,7 +203,7 @@ type NodeRegistry struct {
178203
// when multiple instances (frontend + workers) start at the same time.
179204
func NewNodeRegistry(db *gorm.DB) (*NodeRegistry, error) {
180205
if err := advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error {
181-
return db.AutoMigrate(&BackendNode{}, &NodeModel{}, &NodeLabel{}, &ModelSchedulingConfig{}, &PendingBackendOp{})
206+
return db.AutoMigrate(&BackendNode{}, &NodeModel{}, &NodeLabel{}, &ModelSchedulingConfig{}, &PendingBackendOp{}, &ModelLoadInfo{})
182207
}); err != nil {
183208
return nil, fmt.Errorf("migrating node tables: %w", err)
184209
}
@@ -622,10 +647,54 @@ func (r *NodeRegistry) SetNodeModelLoadInfo(ctx context.Context, nodeID, modelNa
622647
Updates(map[string]any{"backend_type": backendType, "model_opts_blob": optsBlob}).Error
623648
}
624649

650+
// UpsertModelLoadInfo records or replaces the per-model load info in the
651+
// dedicated ModelLoadInfo table. Unlike SetNodeModelLoadInfo (which writes the
652+
// blob onto a specific replica row and dies with it), this survives every
653+
// NodeModel row being removed and so lets the reconciler recover replicas
654+
// after worker death + frontend restart (Bug-1).
655+
//
656+
// ON CONFLICT updates backend_type, model_opts_blob, and updated_at. Two
657+
// frontends dispatching the same model concurrently with slightly different
658+
// opts converge on whichever transaction committed last; that matches the
659+
// existing per-replica blob semantics today.
660+
func (r *NodeRegistry) UpsertModelLoadInfo(ctx context.Context, modelName, backendType string, optsBlob []byte) error {
661+
if modelName == "" {
662+
return fmt.Errorf("model name is required")
663+
}
664+
now := time.Now()
665+
rec := ModelLoadInfo{
666+
ModelName: modelName,
667+
BackendType: backendType,
668+
ModelOptsBlob: optsBlob,
669+
CreatedAt: now,
670+
UpdatedAt: now,
671+
}
672+
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
673+
Columns: []clause.Column{{Name: "model_name"}},
674+
DoUpdates: clause.Assignments(map[string]any{
675+
"backend_type": backendType,
676+
"model_opts_blob": optsBlob,
677+
"updated_at": now,
678+
}),
679+
}).Create(&rec).Error
680+
}
681+
625682
// GetModelLoadInfo retrieves the stored backend type and serialized model
626-
// options from any existing loaded replica. Returns gorm.ErrRecordNotFound
627-
// if no replica has stored options.
683+
// options. Reads from the dedicated ModelLoadInfo table first (survives every
684+
// NodeModel row being deleted); falls back to scanning loaded NodeModel rows
685+
// for the load info stamped before any frontend in this cluster ran an
686+
// UpsertModelLoadInfo (rolling-upgrade transition). Returns
687+
// gorm.ErrRecordNotFound when neither source has an entry.
628688
func (r *NodeRegistry) GetModelLoadInfo(ctx context.Context, modelName string) (backendType string, optsBlob []byte, err error) {
689+
var info ModelLoadInfo
690+
err = r.db.WithContext(ctx).Where("model_name = ?", modelName).First(&info).Error
691+
if err == nil {
692+
return info.BackendType, info.ModelOptsBlob, nil
693+
}
694+
if !errors.Is(err, gorm.ErrRecordNotFound) {
695+
return "", nil, err
696+
}
697+
629698
var nm NodeModel
630699
err = r.db.WithContext(ctx).
631700
Where("model_name = ? AND state = ? AND model_opts_blob IS NOT NULL", modelName, "loaded").

core/services/nodes/router_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ func (f *fakeModelRouter) SetNodeModelLoadInfo(_ context.Context, _, _ string, _
159159
return nil
160160
}
161161

162+
func (f *fakeModelRouter) UpsertModelLoadInfo(_ context.Context, _, _ string, _ []byte) error {
163+
return nil
164+
}
165+
162166
func (f *fakeModelRouter) GetModelLoadInfo(_ context.Context, _ string) (string, []byte, error) {
163167
return "", nil, fmt.Errorf("not found")
164168
}

0 commit comments

Comments
 (0)