mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 03:15:34 +02:00
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
182 lines
6.3 KiB
Go
182 lines
6.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime model catalog cache (stale-while-revalidate)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Listing a runtime's models is a round trip to the user's machine: the request
|
|
// waits for the daemon's next heartbeat, the daemon shells out to the provider
|
|
// CLI (or drives an ACP handshake) and only then reports back. Even with the
|
|
// pending-work push hint (MUL-5444) that is seconds of latency on a UI surface
|
|
// people open repeatedly while filling in one form — switch runtime, look at the
|
|
// models, switch back.
|
|
//
|
|
// The catalog itself changes only when the user upgrades a CLI, logs into a
|
|
// different account, or edits a provider config, so it is a textbook
|
|
// stale-while-revalidate candidate: answer from the last known good snapshot
|
|
// immediately, and refresh in the background so the NEXT open is also warm.
|
|
//
|
|
// Windows are deliberately conservative:
|
|
// - modelCatalogServeWindow bounds how stale an answer the API will hand a
|
|
// client without waiting for the daemon.
|
|
// - modelCatalogRevalidateAfter bounds how long a snapshot can be served
|
|
// without triggering a background refresh, so a CLI upgrade converges
|
|
// within one open instead of lingering for the whole serve window.
|
|
//
|
|
// Only successful, non-empty, `supported` catalogs are cached. An empty list is
|
|
// almost always a transient discovery failure (CLI not logged in, timeout) —
|
|
// caching it would pin the picker empty (same reasoning as agent.cachedDiscovery
|
|
// in the daemon).
|
|
|
|
const (
|
|
// modelCatalogServeWindow is how long a cached catalog may answer a
|
|
// list-models request without waiting for the daemon.
|
|
modelCatalogServeWindow = 15 * time.Minute
|
|
// modelCatalogRevalidateAfter is the age past which serving from cache also
|
|
// enqueues a background refresh.
|
|
modelCatalogRevalidateAfter = 60 * time.Second
|
|
)
|
|
|
|
// ModelCatalogSnapshot is the last known good model list for one runtime.
|
|
type ModelCatalogSnapshot struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Models []ModelEntry `json:"models"`
|
|
Supported bool `json:"supported"`
|
|
StoredAt time.Time `json:"stored_at"`
|
|
}
|
|
|
|
// Age reports how long ago the snapshot was captured.
|
|
func (s *ModelCatalogSnapshot) Age(now time.Time) time.Duration {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
return now.Sub(s.StoredAt)
|
|
}
|
|
|
|
// ModelCatalogCache stores the last successful model catalog per runtime. Both
|
|
// methods are best-effort from the caller's perspective: a Get error means
|
|
// "answer the slow way" and a Put error means "the next open is cold". Neither
|
|
// may fail a request.
|
|
//
|
|
// Implementations must be safe for concurrent use.
|
|
type ModelCatalogCache interface {
|
|
Get(ctx context.Context, runtimeID string) (*ModelCatalogSnapshot, error)
|
|
Put(ctx context.Context, runtimeID string, models []ModelEntry, supported bool) error
|
|
// Invalidate drops any snapshot for the runtime. Used when the cached
|
|
// catalog can no longer be trusted (e.g. the runtime row was deleted).
|
|
Invalidate(ctx context.Context, runtimeID string) error
|
|
}
|
|
|
|
// cacheableModelCatalog reports whether a completed discovery result is worth
|
|
// remembering. `supported=false` runtimes have no picker at all, and an empty
|
|
// catalog is treated as a transient failure rather than an authoritative
|
|
// "this runtime has no models".
|
|
func cacheableModelCatalog(models []ModelEntry, supported bool) bool {
|
|
return supported && len(models) > 0
|
|
}
|
|
|
|
// cloneModelEntries deep-copies a catalog so the in-memory backend hands out
|
|
// values a caller cannot mutate into the shared cache. A shallow slice copy is
|
|
// not enough: ModelEntry carries a *ModelThinking (with its own level slice) and
|
|
// a ServiceTiers slice, all of which would still alias the cached objects. The
|
|
// Redis backend gets this for free by round-tripping through JSON, and the two
|
|
// implementations must not differ in whether the returned value is independent.
|
|
func cloneModelEntries(models []ModelEntry) []ModelEntry {
|
|
if models == nil {
|
|
return nil
|
|
}
|
|
out := make([]ModelEntry, len(models))
|
|
for i, m := range models {
|
|
clone := m
|
|
if m.Thinking != nil {
|
|
thinking := *m.Thinking
|
|
if m.Thinking.SupportedLevels != nil {
|
|
thinking.SupportedLevels = append([]ThinkingLevel(nil), m.Thinking.SupportedLevels...)
|
|
}
|
|
clone.Thinking = &thinking
|
|
}
|
|
if m.ServiceTiers != nil {
|
|
clone.ServiceTiers = append([]ModelServiceTier(nil), m.ServiceTiers...)
|
|
}
|
|
out[i] = clone
|
|
}
|
|
return out
|
|
}
|
|
|
|
// InMemoryModelCatalogCache is the single-node implementation. Adequate for
|
|
// self-hosted and tests; multi-node deploys should use the Redis backend so
|
|
// every API replica shares one warm catalog.
|
|
type InMemoryModelCatalogCache struct {
|
|
mu sync.Mutex
|
|
entries map[string]ModelCatalogSnapshot
|
|
retainFor time.Duration
|
|
}
|
|
|
|
func NewInMemoryModelCatalogCache() *InMemoryModelCatalogCache {
|
|
return &InMemoryModelCatalogCache{
|
|
entries: make(map[string]ModelCatalogSnapshot),
|
|
retainFor: modelCatalogServeWindow,
|
|
}
|
|
}
|
|
|
|
func (c *InMemoryModelCatalogCache) Get(_ context.Context, runtimeID string) (*ModelCatalogSnapshot, error) {
|
|
if runtimeID == "" {
|
|
return nil, nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
entry, ok := c.entries[runtimeID]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
if time.Since(entry.StoredAt) > c.retainFor {
|
|
delete(c.entries, runtimeID)
|
|
return nil, nil
|
|
}
|
|
// Copy so a caller mutating the response cannot corrupt the cache.
|
|
snapshot := entry
|
|
snapshot.Models = cloneModelEntries(entry.Models)
|
|
return &snapshot, nil
|
|
}
|
|
|
|
func (c *InMemoryModelCatalogCache) Put(_ context.Context, runtimeID string, models []ModelEntry, supported bool) error {
|
|
if runtimeID == "" || !cacheableModelCatalog(models, supported) {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Garbage-collect expired entries so the map can't grow unbounded as
|
|
// runtimes come and go.
|
|
now := time.Now()
|
|
for id, entry := range c.entries {
|
|
if now.Sub(entry.StoredAt) > c.retainFor {
|
|
delete(c.entries, id)
|
|
}
|
|
}
|
|
|
|
c.entries[runtimeID] = ModelCatalogSnapshot{
|
|
RuntimeID: runtimeID,
|
|
Models: cloneModelEntries(models),
|
|
Supported: supported,
|
|
StoredAt: now,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *InMemoryModelCatalogCache) Invalidate(_ context.Context, runtimeID string) error {
|
|
if runtimeID == "" {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.entries, runtimeID)
|
|
return nil
|
|
}
|