Files
LocalAI/core/services/addons/journal.go
T
Ettore Di Giacinto 8011e54ddd fix(addons): answer reads while the owner runs an upgrade
An install or upgrade holds the owner's state lock through the backup,
the commit and the start of the new generation. The apps list, the host
access overview and the installed list took the same lock, so they
waited for the whole operation and the frontends timed out after 65 s.
The Add-ons page then had no list to show.

Reads now work on a snapshot of the journal that every journal write
publishes, and never wait for an operation. The apps list reports the
running operation of each add-on (kind, phase, since), and the owner
records the stage of an upgrade through the start of the candidate. A
second upgrade of an add-on that is changing is refused with a conflict.

A frontend waits eight seconds for a quick read of the owner. When the
owner does not answer, the apps list returns the last list it got for
the same person, marked stale for ten minutes. Without such a list the
route answers a typed owner_busy or owner_unavailable 503, and the pins
route no longer drops pins it could not check.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 17:41:18 +00:00

105 lines
2.3 KiB
Go

// SPDX-License-Identifier: MIT
package addons
import (
"encoding/json"
"os"
"path/filepath"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
type journalOperation struct {
Async bool
Command LifecycleCommand
Actor contract.Actor
Status OperationStatus
Next *Status
// StartedAt is when the owner accepted the operation, RFC 3339 in UTC.
// Empty for an operation recorded before the field.
StartedAt string `json:",omitempty"`
}
type journal struct {
Version int
Addons map[string]Status
Operations map[string]journalOperation
}
// Each transition replaces one complete snapshot. File and directory fsyncs
// make the journal's state and operation receipt a single durability boundary.
func durableWrite(path string, b []byte) error {
f, e := os.CreateTemp(filepath.Dir(path), ".journal-")
if e != nil {
return e
}
defer func() { _ = os.Remove(f.Name()) }()
if _, e = f.Write(b); e == nil {
e = f.Sync()
}
closeErr := f.Close()
if e != nil {
return e
}
if closeErr != nil {
return closeErr
}
if e = os.Rename(f.Name(), path); e != nil {
return e
}
d, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer func() { _ = d.Close() }()
return d.Sync()
}
func (m *Manager) persist(phase string) error {
err := m.persistJournal(phase)
m.refreshUpgradeViews()
m.refreshReadView()
return err
}
func (m *Manager) persistJournal(phase string) error {
b, e := json.Marshal(m.journal)
if e == nil {
e = durableWrite(filepath.Join(m.config.Root, "journal.json"), b)
}
if e != nil {
m.uncertain = true
return e
}
if m.boundary != nil {
if e = m.boundary(phase); e != nil {
m.uncertain = true
return e
}
}
return nil
}
func (m *Manager) load() error {
b, e := os.ReadFile(filepath.Join(m.config.Root, "journal.json"))
if os.IsNotExist(e) {
m.journal = journal{Version: 1, Addons: map[string]Status{}, Operations: map[string]journalOperation{}}
return nil
}
if e != nil {
return e
}
var j journal
if e = json.Unmarshal(b, &j); e != nil {
return e
}
if j.Version != 1 || j.Addons == nil || j.Operations == nil {
return ErrConfiguration
}
m.journal = j
m.rebuildCompleted()
return nil
}
func copyStatus(s Status) Status {
b, _ := json.Marshal(s)
var out Status
_ = json.Unmarshal(b, &out)
return out
}