Skip to content

perf: cache sorted model lists in registry to avoid per-request sorting#93

Merged
SantiagoDePolonia merged 2 commits intomainfrom
fix/optimizing-sorting-overhead2
Feb 25, 2026
Merged

perf: cache sorted model lists in registry to avoid per-request sorting#93
SantiagoDePolonia merged 2 commits intomainfrom
fix/optimizing-sorting-overhead2

Conversation

@SantiagoDePolonia
Copy link
Copy Markdown
Contributor

@SantiagoDePolonia SantiagoDePolonia commented Feb 25, 2026

ListModels(), ListModelsWithProvider(), and ListModelsWithProviderByCategory() were allocating and sorting a new slice on every call. For registries with hundreds of models under heavy concurrent load, this wastes CPU and adds GC pressure. Cache sorted slices internally and rebuild lazily (double-checked locking) only when Initialize(), LoadFromCache(), or EnrichModels() modify the underlying models map.

Summary by CodeRabbit

  • Performance Improvements

    • Implemented intelligent caching for model listings and category filters to improve response times.
  • New Features

    • Added asynchronous initialization support for faster startup.
    • Added automatic background refresh capability to keep model lists current at configurable intervals.

ListModels(), ListModelsWithProvider(), and ListModelsWithProviderByCategory()
were allocating and sorting a new slice on every call. For registries with
hundreds of models under heavy concurrent load, this wastes CPU and adds GC
pressure. Cache sorted slices internally and rebuild lazily (double-checked
locking) only when Initialize(), LoadFromCache(), or EnrichModels() modify
the underlying models map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 25, 2026

Warning

Rate limit exceeded

@SantiagoDePolonia has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 59 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between be49a8e and 35bbb2f.

📒 Files selected for processing (1)
  • internal/providers/registry.go
📝 Walkthrough

Walkthrough

Added lazy in-memory caching for sorted models and category-filtered results in the registry, with cache invalidation on updates. Introduced asynchronous initialization and periodic background refresh capabilities with configurable intervals and optional model list fetching.

Changes

Cohort / File(s) Summary
Registry Caching & Async Operations
internal/providers/registry.go
Introduced in-memory caching for sorted model lists and category-filtered results with invalidation logic. Added NewModelRegistry() constructor, InitializeAsync() for async initialization, and StartBackgroundRefresh() for periodic cache refresh with cancellation support. Cache invalidation integrated into existing update paths (EnrichModels, Initialize, LoadFromCache).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 Caches bloom like spring clover,
Sorted lists no longer a rover,
Async whispers refresh the night,
Background tasks keep memories bright,
Swift and nimble, oh what delight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: caching sorted model lists to avoid repeated per-request sorting, which directly aligns with the performance optimization described in the PR objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/optimizing-sorting-overhead2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds performance caching to the ModelRegistry by maintaining sorted slices that are rebuilt lazily only when the underlying models change. The optimization targets three frequently-called methods (ListModels, ListModelsWithProvider, and ListModelsWithProviderByCategory) that previously allocated and sorted new slices on every invocation.

Changes:

  • Added three cache fields to ModelRegistry: sortedModels, sortedModelsWithProvider, and categoryCache (per-category results)
  • Implemented double-checked locking pattern for lazy cache initialization in all three List methods
  • Added invalidateSortedCaches() helper called by Initialize(), LoadFromCache(), and EnrichModels() to clear caches when models change

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 373 to 395
func (r *ModelRegistry) ListModels() []core.Model {
r.mu.RLock()
defer r.mu.RUnlock()
if cached := r.sortedModels; cached != nil {
r.mu.RUnlock()
return cached
}
r.mu.RUnlock()

r.mu.Lock()
defer r.mu.Unlock()
// Double-check: another goroutine may have built it while we waited for the lock.
if r.sortedModels != nil {
return r.sortedModels
}

models := make([]core.Model, 0, len(r.models))
for _, info := range r.models {
models = append(models, info.Model)
}
sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID })

// Sort by model ID for consistent ordering across calls
sort.Slice(models, func(i, j int) bool {
return models[i].ID < models[j].ID
})

r.sortedModels = models
return models
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cached slice is returned directly to callers without making a defensive copy. This creates a critical data race: if a caller modifies the returned slice (e.g., sorting, appending, or modifying Model fields), those modifications will affect the cached slice visible to all other goroutines. Additionally, if callers retain references to the slice and a cache invalidation occurs, they could continue reading stale data while new goroutines see updated data, leading to inconsistencies.

The same issue exists in ListModelsWithProvider() and ListModelsWithProviderByCategory().

To fix this, return a copy of the cached slice. While this adds allocation overhead, it's still much better than the original behavior of sorting on every call, and it guarantees thread safety. The copy should be shallow (copy the slice but not the Model structs themselves, since Model structs contain only immutable fields or fields protected by the registry lock).

Copilot uses AI. Check for mistakes.
Comment on lines 427 to 451
@@ -409,37 +445,53 @@ func (r *ModelRegistry) ListModelsWithProvider() []ModelWithProvider {
ProviderType: r.providerTypes[info.Provider],
})
}
sort.Slice(result, func(i, j int) bool { return result[i].Model.ID < result[j].Model.ID })

sort.Slice(result, func(i, j int) bool {
return result[i].Model.ID < result[j].Model.ID
})

r.sortedModelsWithProvider = result
return result
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cached slice is returned directly to callers without making a defensive copy. This creates the same critical data race as in ListModels(): callers can modify the returned slice, affecting the cached data visible to all goroutines. Return a shallow copy of the cached slice to prevent this race condition.

Copilot uses AI. Check for mistakes.
Comment on lines 457 to 495
func (r *ModelRegistry) ListModelsWithProviderByCategory(category core.ModelCategory) []ModelWithProvider {
if category == core.CategoryAll {
return r.ListModelsWithProvider()
}

r.mu.RLock()
defer r.mu.RUnlock()
if r.categoryCache != nil {
if cached, ok := r.categoryCache[category]; ok {
r.mu.RUnlock()
return cached
}
}
r.mu.RUnlock()

r.mu.Lock()
defer r.mu.Unlock()
if r.categoryCache != nil {
if cached, ok := r.categoryCache[category]; ok {
return cached
}
}

result := make([]ModelWithProvider, 0)
for _, info := range r.models {
if category != core.CategoryAll {
if info.Model.Metadata == nil || !hasCategory(info.Model.Metadata.Categories, category) {
continue
}
if info.Model.Metadata == nil || !hasCategory(info.Model.Metadata.Categories, category) {
continue
}
result = append(result, ModelWithProvider{
Model: info.Model,
ProviderType: r.providerTypes[info.Provider],
})
}
sort.Slice(result, func(i, j int) bool { return result[i].Model.ID < result[j].Model.ID })

sort.Slice(result, func(i, j int) bool {
return result[i].Model.ID < result[j].Model.ID
})

if r.categoryCache == nil {
r.categoryCache = make(map[core.ModelCategory][]ModelWithProvider)
}
r.categoryCache[category] = result
return result
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cached slice is returned directly to callers without making a defensive copy. This creates the same critical data race as in the other List methods: callers can modify the returned slice, affecting the cached data visible to all goroutines. Return a shallow copy of the cached slice to prevent this race condition.

Copilot uses AI. Check for mistakes.
@SantiagoDePolonia
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 25, 2026

@SantiagoDePolonia: I'll review the changes in this PR focusing on the performance optimization for caching sorted model lists in the registry.


🧠 Learnings used
Learnt from: CR
Repo: ENTERPILOT/GOModel PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-24T18:10:53.752Z
Learning: Applies to cmd/gomodel/main.go : Providers must be registered explicitly via `factory.Add()` in `cmd/gomodel/main.go`; order matters as first registered wins for duplicate model names

Learnt from: CR
Repo: ENTERPILOT/GOModel PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-24T18:10:53.752Z
Learning: Applies to internal/providers/router.go : Router requires an initialized registry; check `ModelCount() > 0` before routing to ensure registry is ready

Learnt from: SantiagoDePolonia
Repo: ENTERPILOT/GOModel PR: 43
File: internal/providers/anthropic/anthropic.go:405-426
Timestamp: 2026-01-21T13:29:21.053Z
Learning: Anthropic's model count is always below 1000 models, so pagination is not needed in the ListModels implementation for the Anthropic provider in internal/providers/anthropic/anthropic.go.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/providers/registry.go`:
- Around line 375-377: The code currently returns the internal cached slice
r.sortedModels (and other cached slices) directly which allows callers to mutate
shared state; instead, always return a defensive copy when returning cached
slices: after retrieving cached := r.sortedModels (and analogous cached
variables) make and return a copy (e.g., newSlice := append([]ModelType(nil),
cached...)) while preserving the existing locking/unlocking (r.mu.RUnlock() /
r.mu.Unlock()) and keeping the cache itself untouched; also apply the same
defensive-copy pattern to all places that return the cache after building it so
callers never receive a reference to the internal slice.
- Around line 491-494: The cache currently stores every incoming category
(r.categoryCache[category] = result), allowing unbounded growth; change it to
only cache known/expected categories by validating the category before inserting
into r.categoryCache (e.g., compare the category value against the canonical set
of core.ModelCategory values or a small allowedCategories whitelist) and skip
caching if it is not recognized; reference r.categoryCache, the category
variable and ModelWithProvider/ core.ModelCategory in your change, and keep the
existing nil-check/initialization of r.categoryCache intact.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aaa5e24 and be49a8e.

📒 Files selected for processing (1)
  • internal/providers/registry.go

…ry cache

Two issues in the sorted-list caching introduced in the previous commit:

1. Cached slices were returned by reference, allowing callers to mutate
   shared internal state (core.Model contains a *ModelMetadata pointer).
   Now all return paths copy the cached slice before returning.

2. categoryCache accepted any ModelCategory string value, allowing
   unbounded map growth from arbitrary input. Now only the 6 known
   categories (text_generation, embedding, image, audio, video, utility)
   are cached; unrecognized categories still produce correct results but
   skip the cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@SantiagoDePolonia SantiagoDePolonia merged commit 8dcaa58 into main Feb 25, 2026
12 checks passed
@SantiagoDePolonia SantiagoDePolonia deleted the fix/optimizing-sorting-overhead2 branch March 22, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants