gui: the panel's reads build the catalog once per request (owner: 这个应该是 magpie API 慢, on a Discord report that the window renders slowly) — provider.Resolve rebuilt every provider's catalog (24 providers, 270 models: 35-90ms) for each model it was asked about, and /api/state and /api/groups resolve one for every agent and row; plugin.Cached re-read the plugins' files for each PluginOf, and resolveIn's fallbacks read All() and every provider's Available() again. A GET under /api/ now holds what it builds (provider.Hold: the entries, the plugins, All, which providers list a model) for the request, at most 1s while requests overlap; a save drops it (catalog.Touched → catalog.Forget, plugin.OnChange, setMigration, and any non-GET request). Outside a request nothing is held, so the gateway and tests behave as before. On the owner's config: state 4.2s → 0.42s, groups 6.7s → 0.26s, providers 1.4s → 0.33s. Sandbox (fixed build, GETs looping): a model saved through /api/provider/save is in providers, groups and state at once and gone after the restore. TestHoldBuildsOnce, TestHoldSeesWrites (fails with Forget unset). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
b41090f8fa
commit
dff14ff465
8 files changed
+220
-15
No files matched your search
@@ -127,8 +127,15 @@ func SaveLive(provider, base string, models []Model) error {
|
|||||||
// of their own up to date.
|
// of their own up to date.
|
||||||
var Changed func()
|
var Changed func()
|
||||||
|
|
||||||
// Touched tells Changed, if set.
|
// Forget, when set, is told so first: the provider package drops the
|
||||||
|
// catalog a request holds (provider.Hold).
|
||||||
|
var Forget func()
|
||||||
|
|
||||||
|
// Touched tells Forget and Changed, if set.
|
||||||
func Touched() {
|
func Touched() {
|
||||||
|
if Forget != nil {
|
||||||
|
Forget()
|
||||||
|
}
|
||||||
if Changed != nil {
|
if Changed != nil {
|
||||||
Changed()
|
Changed()
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -988,7 +988,21 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
|||||||
})
|
})
|
||||||
agentModelsAPI(mux)
|
agentModelsAPI(mux)
|
||||||
devListen(mux)
|
devListen(mux)
|
||||||
return mux
|
return held(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// held has a page's reads share one build of the catalog, which every row
|
||||||
|
// resolving its model rebuilt (provider.Hold): /api/state took 4s with a
|
||||||
|
// few hundred models. Anything else may have written, and drops it.
|
||||||
|
func held(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
defer provider.Hold()()
|
||||||
|
} else {
|
||||||
|
defer provider.Changed()
|
||||||
|
}
|
||||||
|
h.ServeHTTP(rw, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func state() stateJSON {
|
func state() stateJSON {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yetone/magpie/internal/catalog"
|
||||||
|
"github.com/yetone/magpie/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is rebuilt from every provider's files on each Resolve (tens
|
||||||
|
// of milliseconds with a few hundred models), and a page of the GUI
|
||||||
|
// resolves a model for each row: /api/state and /api/groups took 4s and
|
||||||
|
// 7s with 24 providers. A request that only reads holds the catalog it
|
||||||
|
// built first for all its look-ups.
|
||||||
|
var held struct {
|
||||||
|
sync.Mutex
|
||||||
|
holds int
|
||||||
|
gen uint64
|
||||||
|
// what was built, and when, by what it is
|
||||||
|
built map[string]heldValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type heldValue struct {
|
||||||
|
v any
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
catalog.Forget = Changed
|
||||||
|
// a plugin's sign-in saved or renewed, a plugin added
|
||||||
|
plugin.OnChange(Changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldFor is how long a held catalog is used before it is built again,
|
||||||
|
// while one request after another keeps it held.
|
||||||
|
const heldFor = time.Second
|
||||||
|
|
||||||
|
// Hold keeps the catalog built once until release is called, for a
|
||||||
|
// request that only reads; a write meanwhile (Changed) drops it.
|
||||||
|
func Hold() (release func()) {
|
||||||
|
held.Lock()
|
||||||
|
held.holds++
|
||||||
|
held.Unlock()
|
||||||
|
return sync.OnceFunc(func() {
|
||||||
|
held.Lock()
|
||||||
|
if held.holds--; held.holds == 0 {
|
||||||
|
held.built = nil
|
||||||
|
}
|
||||||
|
held.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed drops what is held: a provider, an account, a plugin or a
|
||||||
|
// setting was written.
|
||||||
|
func Changed() {
|
||||||
|
held.Lock()
|
||||||
|
held.gen++
|
||||||
|
held.built = nil
|
||||||
|
held.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldOf is what build gives, built once while a request holds it.
|
||||||
|
func heldOf[T any](key string, build func() T) T {
|
||||||
|
held.Lock()
|
||||||
|
if held.holds == 0 {
|
||||||
|
held.Unlock()
|
||||||
|
return build()
|
||||||
|
}
|
||||||
|
if h, ok := held.built[key]; ok && time.Since(h.at) < heldFor {
|
||||||
|
held.Unlock()
|
||||||
|
return h.v.(T)
|
||||||
|
}
|
||||||
|
gen := held.gen
|
||||||
|
held.Unlock()
|
||||||
|
v := build()
|
||||||
|
held.Lock()
|
||||||
|
if held.holds > 0 && held.gen == gen {
|
||||||
|
if held.built == nil {
|
||||||
|
held.built = map[string]heldValue{}
|
||||||
|
}
|
||||||
|
held.built[key] = heldValue{v, time.Now()}
|
||||||
|
}
|
||||||
|
held.Unlock()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func heldEntries(build func() []Entry) []Entry {
|
||||||
|
// clipped: a caller's append copies
|
||||||
|
return heldOf("entries", func() []Entry { return slices.Clip(build()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldPlugins is plugin.Cached, which reads the plugins' list and
|
||||||
|
// sign-ins each time, and PluginOf asks for every model.
|
||||||
|
func heldPlugins() []plugin.Provider {
|
||||||
|
return heldOf("plugins", func() []plugin.Provider { return slices.Clip(plugin.Cached()) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A request that only reads builds the catalog once for all its look-ups
|
||||||
|
// (/api/state took 4s, resolving each agent's models anew); outside one,
|
||||||
|
// and after a write, it is built again.
|
||||||
|
func TestHoldBuildsOnce(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
build := func() int { n++; return n }
|
||||||
|
heldOf("t", build)
|
||||||
|
heldOf("t", build)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("not held: built %d times, want 2", n)
|
||||||
|
}
|
||||||
|
release := Hold()
|
||||||
|
heldOf("t", build)
|
||||||
|
heldOf("t", build)
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("held: built %d times, want 3", n)
|
||||||
|
}
|
||||||
|
Changed()
|
||||||
|
if heldOf("t", build); n != 4 {
|
||||||
|
t.Fatalf("after a write: built %d times, want 4", n)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
release() // a second release is no second one
|
||||||
|
if heldOf("t", build); n != 5 {
|
||||||
|
t.Fatalf("released: built %d times, want 5", n)
|
||||||
|
}
|
||||||
|
if held.holds != 0 {
|
||||||
|
t.Fatalf("holds left: %d", held.holds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider saved while a request holds the catalog is in it at once.
|
||||||
|
func TestHoldSeesWrites(t *testing.T) {
|
||||||
|
if err := Save(Provider{ID: "held", Name: "Held", Chat: "https://held.example/v1", Key: "k", Models: []string{"a"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer Hold()()
|
||||||
|
ids := func() []string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
out = append(out, e.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if !slices.Contains(ids(), "held/a") || slices.Contains(ids(), "held/b") {
|
||||||
|
t.Fatalf("before: %v", ids())
|
||||||
|
}
|
||||||
|
if err := Save(Provider{ID: "held", Name: "Held", Chat: "https://held.example/v1", Key: "k", Models: []string{"a", "b"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(ids(), "held/b") {
|
||||||
|
t.Fatalf("a model picked meanwhile is missing: %v", ids())
|
||||||
|
}
|
||||||
|
if p, m, ok := Resolve("held/b"); !ok || p.ID != "held" || m != "b" {
|
||||||
|
t.Fatalf("Resolve: %v %q %v", p.ID, m, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -354,6 +354,7 @@ func setMigration(id string, f func(m *Migration)) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer Changed()
|
||||||
return writePrivate(migrationsPath(), append(b, '\n'))
|
return writePrivate(migrationsPath(), append(b, '\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -910,6 +910,10 @@ func Unlisted() []Entry {
|
|||||||
|
|
||||||
// providerEntries is the catalog without its groups.
|
// providerEntries is the catalog without its groups.
|
||||||
func providerEntries() []Entry {
|
func providerEntries() []Entry {
|
||||||
|
return heldEntries(buildEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEntries() []Entry {
|
||||||
var out []Entry
|
var out []Entry
|
||||||
s := settings.Load()
|
s := settings.Load()
|
||||||
for _, p := range All() {
|
for _, p := range All() {
|
||||||
@@ -1043,7 +1047,9 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pid, model, ok := strings.Cut(id, "/"); ok {
|
if pid, model, ok := strings.Cut(id, "/"); ok {
|
||||||
if p, err := Find(pid); err == nil && p.On() {
|
// the providers a request holds: an agent's value of a model not
|
||||||
|
// listed lands here, and All is read anew each time
|
||||||
|
if p, err := findIn(heldOf("all", All), pid); err == nil && p.On() {
|
||||||
return *p, model, true
|
return *p, model, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1058,15 +1064,9 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
|
|||||||
}
|
}
|
||||||
// not exposed, but some provider lists it
|
// not exposed, but some provider lists it
|
||||||
var found []Provider
|
var found []Provider
|
||||||
for _, p := range All() {
|
for _, p := range heldOf("listed", listedBy)[id] {
|
||||||
if !p.On() || p.DecidesModel(id) {
|
if !p.DecidesModel(id) {
|
||||||
continue
|
found = append(found, p)
|
||||||
}
|
|
||||||
for _, m := range p.Available() {
|
|
||||||
if m.ID == id {
|
|
||||||
found = append(found, p)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(found) == 1 {
|
if len(found) == 1 {
|
||||||
@@ -1075,6 +1075,24 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
|
|||||||
return Provider{}, "", false
|
return Provider{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listedBy is the providers on that list each model, in order, each once.
|
||||||
|
func listedBy() map[string][]Provider {
|
||||||
|
out := map[string][]Provider{}
|
||||||
|
for _, p := range All() {
|
||||||
|
if !p.On() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range p.Available() {
|
||||||
|
if !seen[m.ID] {
|
||||||
|
seen[m.ID] = true
|
||||||
|
out[m.ID] = append(out[m.ID], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// IDs lists the catalog ids, for error messages.
|
// IDs lists the catalog ids, for error messages.
|
||||||
func IDs() []string {
|
func IDs() []string {
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (p Provider) PluginProvider() string {
|
|||||||
|
|
||||||
// PluginOf is the plugin provider magpie's provider id is, when it is one.
|
// PluginOf is the plugin provider magpie's provider id is, when it is one.
|
||||||
func PluginOf(id string) (plugin.Provider, bool) {
|
func PluginOf(id string) (plugin.Provider, bool) {
|
||||||
for _, pp := range plugin.Cached() {
|
for _, pp := range heldPlugins() {
|
||||||
if PluginID(pp.ID) == id {
|
if PluginID(pp.ID) == id {
|
||||||
return pp, true
|
return pp, true
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ func PluginOf(id string) (plugin.Provider, bool) {
|
|||||||
// first account.
|
// first account.
|
||||||
func pluginAccounts() []Provider {
|
func pluginAccounts() []Provider {
|
||||||
var out []Provider
|
var out []Provider
|
||||||
for _, pp := range plugin.Cached() {
|
for _, pp := range heldPlugins() {
|
||||||
if movingNow(pp.ID) {
|
if movingNow(pp.ID) {
|
||||||
continue // shown once the move is through (migrate.go)
|
continue // shown once the move is through (migrate.go)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,8 +351,11 @@ func find(ps []Provider, id string) (Provider, bool) {
|
|||||||
|
|
||||||
// Find looks a provider up by id (or name, case-insensitively).
|
// Find looks a provider up by id (or name, case-insensitively).
|
||||||
func Find(id string) (*Provider, error) {
|
func Find(id string) (*Provider, error) {
|
||||||
|
return findIn(All(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findIn(all []Provider, id string) (*Provider, error) {
|
||||||
q := strings.ToLower(strings.TrimSpace(id))
|
q := strings.ToLower(strings.TrimSpace(id))
|
||||||
all := All()
|
|
||||||
// an id before a name: a provider of the user's called WorkBuddy isn't
|
// an id before a name: a provider of the user's called WorkBuddy isn't
|
||||||
// the workbuddy subscription
|
// the workbuddy subscription
|
||||||
if i := slices.IndexFunc(all, func(p Provider) bool { return p.ID == q }); i >= 0 {
|
if i := slices.IndexFunc(all, func(p Provider) bool { return p.ID == q }); i >= 0 {
|
||||||
|
|||||||
Reference in new issue
Block a user