Skip to content

Commit e087d99

Browse files
authored
Merge pull request #2138 from dgageot/board/run-docker-agent-run-examples-gopher-yam-ecbcff53
Optimize start time
2 parents 4bfd2ec + 2b5b738 commit e087d99

4 files changed

Lines changed: 126 additions & 53 deletions

File tree

pkg/history/history.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package history
22

33
import (
44
"encoding/json"
5-
"io"
65
"os"
76
"path/filepath"
87
"slices"
8+
"strconv"
99
"strings"
1010
)
1111

@@ -208,32 +208,53 @@ func (h *History) append(message string) error {
208208
}
209209

210210
func (h *History) load() error {
211-
f, err := os.Open(h.path)
211+
data, err := os.ReadFile(h.path)
212212
if err != nil {
213213
return err
214214
}
215-
defer f.Close()
216215

217-
var all []string
218-
dec := json.NewDecoder(f)
219-
for {
220-
var message string
221-
if err := dec.Decode(&message); err != nil {
222-
if err == io.EOF {
223-
break
224-
}
216+
// Count lines to pre-size the slice.
217+
n := 0
218+
for _, b := range data {
219+
if b == '\n' {
220+
n++
221+
}
222+
}
223+
224+
// Parse all lines. Each line is a JSON-encoded string (e.g. "hello").
225+
// strconv.Unquote handles the same escape sequences as JSON and is
226+
// much faster than json.Unmarshal for quoted strings.
227+
all := make([]string, 0, n)
228+
s := string(data)
229+
for s != "" {
230+
i := strings.IndexByte(s, '\n')
231+
var line string
232+
if i < 0 {
233+
line = s
234+
s = ""
235+
} else {
236+
line = s[:i]
237+
s = s[i+1:]
238+
}
239+
if line == "" {
240+
continue
241+
}
242+
243+
message, err := strconv.Unquote(line)
244+
if err != nil {
225245
continue
226246
}
227247
all = append(all, message)
228248
}
229249

230-
// Deduplicate keeping the latest occurrence of each message
231-
seen := make(map[string]bool)
250+
// Deduplicate keeping the latest occurrence of each message.
251+
seen := make(map[string]struct{}, len(all))
252+
h.Messages = make([]string, 0, len(all))
232253
for i := len(all) - 1; i >= 0; i-- {
233-
if seen[all[i]] {
254+
if _, dup := seen[all[i]]; dup {
234255
continue
235256
}
236-
seen[all[i]] = true
257+
seen[all[i]] = struct{}{}
237258
h.Messages = append(h.Messages, all[i])
238259
}
239260
slices.Reverse(h.Messages)

pkg/modelsdev/store.go

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"log/slog"
89
"net/http"
910
"os"
@@ -22,15 +23,20 @@ const (
2223

2324
// Store manages access to the models.dev data.
2425
// All methods are safe for concurrent use.
26+
//
27+
// Use NewStore to obtain the process-wide singleton instance.
28+
// The database is loaded on first access via GetDatabase and
29+
// shared across all callers, avoiding redundant disk/network I/O.
2530
type Store struct {
2631
cacheFile string
2732
mu sync.Mutex
2833
db *Database
34+
etag string // ETag from last successful fetch, used for conditional requests
2935
}
3036

31-
// NewStore creates a new models.dev store.
32-
// The database is loaded on first access via GetDatabase.
33-
func NewStore() (*Store, error) {
37+
// singleton holds the process-wide Store instance. It is initialised lazily
38+
// on the first call to NewStore. All subsequent calls return the same value.
39+
var singleton = sync.OnceValues(func() (*Store, error) {
3440
homeDir, err := os.UserHomeDir()
3541
if err != nil {
3642
return nil, fmt.Errorf("failed to get user home directory: %w", err)
@@ -44,6 +50,15 @@ func NewStore() (*Store, error) {
4450
return &Store{
4551
cacheFile: filepath.Join(cacheDir, CacheFileName),
4652
}, nil
53+
})
54+
55+
// NewStore returns the process-wide singleton Store.
56+
//
57+
// The database is loaded lazily on the first call to GetDatabase and
58+
// then cached in memory so that every caller shares one copy.
59+
// The first call creates the cache directory if it does not exist.
60+
func NewStore() (*Store, error) {
61+
return singleton()
4762
}
4863

4964
// NewDatabaseStore creates a Store pre-populated with the given database.
@@ -63,12 +78,13 @@ func (s *Store) GetDatabase(ctx context.Context) (*Database, error) {
6378
return s.db, nil
6479
}
6580

66-
db, err := loadDatabase(ctx, s.cacheFile)
81+
db, etag, err := loadDatabase(ctx, s.cacheFile)
6782
if err != nil {
6883
return nil, err
6984
}
7085

7186
s.db = db
87+
s.etag = etag
7288
return db, nil
7389
}
7490

@@ -128,80 +144,117 @@ func (s *Store) GetModel(ctx context.Context, id string) (*Model, error) {
128144

129145
// loadDatabase loads the database from the local cache file or
130146
// falls back to fetching from the models.dev API.
131-
func loadDatabase(ctx context.Context, cacheFile string) (*Database, error) {
147+
// It returns the database and the ETag associated with the data.
148+
func loadDatabase(ctx context.Context, cacheFile string) (*Database, string, error) {
132149
// Try to load from cache first
133150
cached, err := loadFromCache(cacheFile)
134151
if err == nil && time.Since(cached.LastRefresh) < refreshInterval {
135-
return &cached.Database, nil
152+
return &cached.Database, cached.ETag, nil
136153
}
137154

138-
// Cache is invalid or doesn't exist, fetch from API
139-
database, fetchErr := fetchFromAPI(ctx)
155+
// Cache is stale or doesn't exist — try a conditional fetch with the ETag.
156+
var etag string
157+
if cached != nil {
158+
etag = cached.ETag
159+
}
160+
161+
database, newETag, fetchErr := fetchFromAPI(ctx, etag)
140162
if fetchErr != nil {
141-
// If API fetch fails, but we have cached data, use it
163+
// If API fetch fails but we have cached data, use it regardless of age.
142164
if cached != nil {
143-
return &cached.Database, nil
165+
slog.Debug("API fetch failed, using stale cache", "error", fetchErr)
166+
return &cached.Database, cached.ETag, nil
167+
}
168+
return nil, "", fmt.Errorf("failed to fetch from API and no cached data available: %w", fetchErr)
169+
}
170+
171+
// database is nil when the server returned 304 Not Modified.
172+
if database == nil && cached != nil {
173+
// Bump LastRefresh so we don't re-check until the next interval.
174+
cached.LastRefresh = time.Now()
175+
if saveErr := saveToCache(cacheFile, &cached.Database, cached.ETag); saveErr != nil {
176+
slog.Warn("Failed to update cache timestamp", "error", saveErr)
144177
}
145-
return nil, fmt.Errorf("failed to fetch from API and no cached data available: %w", fetchErr)
178+
return &cached.Database, cached.ETag, nil
146179
}
147180

148-
// Save to cache
149-
if err := saveToCache(cacheFile, database); err != nil {
150-
// Log the error but don't fail the request
151-
slog.Warn("Warning: failed to save to cache", "error", err)
181+
// Save the fresh data to cache.
182+
if saveErr := saveToCache(cacheFile, database, newETag); saveErr != nil {
183+
slog.Warn("Failed to save to cache", "error", saveErr)
152184
}
153185

154-
return database, nil
186+
return database, newETag, nil
155187
}
156188

157-
func fetchFromAPI(ctx context.Context) (*Database, error) {
189+
// fetchFromAPI fetches the models.dev database.
190+
// If etag is non-empty it is sent as If-None-Match; a 304 response
191+
// returns (nil, etag, nil) to indicate no change.
192+
func fetchFromAPI(ctx context.Context, etag string) (*Database, string, error) {
158193
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ModelsDevAPIURL, http.NoBody)
159194
if err != nil {
160-
return nil, fmt.Errorf("failed to create request: %w", err)
195+
return nil, "", fmt.Errorf("failed to create request: %w", err)
196+
}
197+
198+
if etag != "" {
199+
req.Header.Set("If-None-Match", etag)
161200
}
162201

163202
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
164203
if err != nil {
165-
return nil, fmt.Errorf("failed to fetch from API: %w", err)
204+
return nil, "", fmt.Errorf("failed to fetch from API: %w", err)
166205
}
167206
defer resp.Body.Close()
168207

208+
if resp.StatusCode == http.StatusNotModified {
209+
slog.Debug("models.dev data not modified (304)")
210+
return nil, etag, nil
211+
}
212+
169213
if resp.StatusCode != http.StatusOK {
170-
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
214+
return nil, "", fmt.Errorf("API returned status %d", resp.StatusCode)
215+
}
216+
217+
// Read the full body then unmarshal — avoids the extra intermediate
218+
// buffering that json.Decoder.Decode performs.
219+
body, err := io.ReadAll(resp.Body)
220+
if err != nil {
221+
return nil, "", fmt.Errorf("failed to read response body: %w", err)
171222
}
172223

173224
var providers map[string]Provider
174-
if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil {
175-
return nil, fmt.Errorf("failed to decode response: %w", err)
225+
if err := json.Unmarshal(body, &providers); err != nil {
226+
return nil, "", fmt.Errorf("failed to decode response: %w", err)
176227
}
177228

229+
newETag := resp.Header.Get("ETag")
230+
178231
return &Database{
179232
Providers: providers,
180233
UpdatedAt: time.Now(),
181-
}, nil
234+
}, newETag, nil
182235
}
183236

184237
func loadFromCache(cacheFile string) (*CachedData, error) {
185-
f, err := os.Open(cacheFile)
238+
data, err := os.ReadFile(cacheFile)
186239
if err != nil {
187-
return nil, fmt.Errorf("failed to open cache file: %w", err)
240+
return nil, fmt.Errorf("failed to read cache file: %w", err)
188241
}
189-
defer f.Close()
190242

191243
var cached CachedData
192-
if err := json.NewDecoder(f).Decode(&cached); err != nil {
244+
if err := json.Unmarshal(data, &cached); err != nil {
193245
return nil, fmt.Errorf("failed to decode cached data: %w", err)
194246
}
195247

196248
return &cached, nil
197249
}
198250

199-
func saveToCache(cacheFile string, database *Database) error {
251+
func saveToCache(cacheFile string, database *Database, etag string) error {
200252
now := time.Now()
201253
cached := CachedData{
202254
Database: *database,
203255
CachedAt: now,
204256
LastRefresh: now,
257+
ETag: etag,
205258
}
206259

207260
data, err := json.MarshalIndent(cached, "", " ")

pkg/modelsdev/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,5 @@ type CachedData struct {
6262
Database Database `json:"database"`
6363
CachedAt time.Time `json:"cached_at"`
6464
LastRefresh time.Time `json:"last_refresh"`
65+
ETag string `json:"etag,omitempty"`
6566
}

pkg/teamloader/teamloader.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ func getModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentC
286286
var models []provider.Provider
287287
thinkingConfigured := false
288288

289+
// Obtain the singleton store once, outside the loop.
290+
modelsStore, modelsStoreErr := modelsdev.NewStore()
291+
289292
for name := range strings.SplitSeq(a.Model, ",") {
290293
modelCfg, exists := cfg.Models[name]
291294
isAutoModel := false
@@ -310,11 +313,7 @@ func getModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentC
310313
maxTokens := &defaultMaxTokens
311314
if modelCfg.MaxTokens != nil {
312315
maxTokens = modelCfg.MaxTokens
313-
} else {
314-
modelsStore, err := modelsdev.NewStore()
315-
if err != nil {
316-
return nil, false, err
317-
}
316+
} else if modelsStoreErr == nil {
318317
m, err := modelsStore.GetModel(ctx, modelCfg.Provider+"/"+modelCfg.Model)
319318
if err == nil {
320319
maxTokens = &m.Limit.Output
@@ -355,6 +354,9 @@ func getModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentC
355354
func getFallbackModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentConfig, runConfig *config.RuntimeConfig) ([]provider.Provider, error) {
356355
var fallbackModels []provider.Provider
357356

357+
// Obtain the singleton store once, outside the loop.
358+
modelsStore, modelsStoreErr := modelsdev.NewStore()
359+
358360
for _, name := range a.GetFallbackModels() {
359361
modelCfg, exists := cfg.Models[name]
360362
if !exists {
@@ -371,11 +373,7 @@ func getFallbackModelsForAgent(ctx context.Context, cfg *latest.Config, a *lates
371373
maxTokens := &defaultMaxTokens
372374
if modelCfg.MaxTokens != nil {
373375
maxTokens = modelCfg.MaxTokens
374-
} else {
375-
modelsStore, err := modelsdev.NewStore()
376-
if err != nil {
377-
return nil, err
378-
}
376+
} else if modelsStoreErr == nil {
379377
m, err := modelsStore.GetModel(ctx, modelCfg.Provider+"/"+modelCfg.Model)
380378
if err == nil {
381379
maxTokens = &m.Limit.Output

0 commit comments

Comments
 (0)