Skip to content

Commit f8084a0

Browse files
authored
feat(solver): prefer highest versions + fix caching/dedupe (#1)
1 parent 8dafd3d commit f8084a0

6 files changed

Lines changed: 194 additions & 25 deletions

File tree

.mise.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[tools]
2-
go = "1.25.3"
2+
go = "latest"

cached_source.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package pubgrub
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
"sync"
6+
)
47

58
// CachedSource wraps a Source and caches GetVersions and GetDependencies calls
69
// to improve performance when the same queries are made repeatedly.
@@ -23,6 +26,8 @@ import "fmt"
2326
type CachedSource struct {
2427
source Source
2528

29+
mu sync.Mutex
30+
2631
// Cache for GetVersions results
2732
versionsCache map[Name][]Version
2833
versionsCalls int
@@ -45,27 +50,37 @@ func NewCachedSource(source Source) *CachedSource {
4550

4651
// GetVersions returns all available versions for a package, caching the result.
4752
func (c *CachedSource) GetVersions(name Name) ([]Version, error) {
53+
c.mu.Lock()
4854
c.versionsCalls++
4955

5056
// Check cache first
5157
if versions, ok := c.versionsCache[name]; ok {
5258
c.versionsCacheHits++
53-
return versions, nil
59+
out := cloneVersions(versions)
60+
c.mu.Unlock()
61+
return out, nil
5462
}
63+
c.mu.Unlock()
5564

5665
// Cache miss - fetch from underlying source
5766
versions, err := c.source.GetVersions(name)
5867
if err != nil {
5968
return nil, err
6069
}
6170

71+
cloned := cloneVersions(versions)
72+
6273
// Store in cache
63-
c.versionsCache[name] = versions
64-
return versions, nil
74+
c.mu.Lock()
75+
c.versionsCache[name] = cloned
76+
c.mu.Unlock()
77+
78+
return cloneVersions(cloned), nil
6579
}
6680

6781
// GetDependencies returns dependencies for a specific package version, caching the result.
6882
func (c *CachedSource) GetDependencies(name Name, version Version) ([]Term, error) {
83+
c.mu.Lock()
6984
c.depsCalls++
7085

7186
// Create cache key from name and version
@@ -74,18 +89,26 @@ func (c *CachedSource) GetDependencies(name Name, version Version) ([]Term, erro
7489
// Check cache first
7590
if deps, ok := c.depsCache[key]; ok {
7691
c.depsCacheHits++
77-
return deps, nil
92+
out := cloneTerms(deps)
93+
c.mu.Unlock()
94+
return out, nil
7895
}
96+
c.mu.Unlock()
7997

8098
// Cache miss - fetch from underlying source
8199
deps, err := c.source.GetDependencies(name, version)
82100
if err != nil {
83101
return nil, err
84102
}
85103

104+
cloned := cloneTerms(deps)
105+
86106
// Store in cache
87-
c.depsCache[key] = deps
88-
return deps, nil
107+
c.mu.Lock()
108+
c.depsCache[key] = cloned
109+
c.mu.Unlock()
110+
111+
return cloneTerms(cloned), nil
89112
}
90113

91114
// CacheStats returns statistics about cache performance.
@@ -105,6 +128,7 @@ type CacheStats struct {
105128

106129
// GetCacheStats returns cache performance statistics.
107130
func (c *CachedSource) GetCacheStats() CacheStats {
131+
c.mu.Lock()
108132
stats := CacheStats{
109133
VersionsCalls: c.versionsCalls,
110134
VersionsCacheHits: c.versionsCacheHits,
@@ -113,6 +137,7 @@ func (c *CachedSource) GetCacheStats() CacheStats {
113137
TotalCalls: c.versionsCalls + c.depsCalls,
114138
TotalCacheHits: c.versionsCacheHits + c.depsCacheHits,
115139
}
140+
c.mu.Unlock()
116141

117142
if stats.VersionsCalls > 0 {
118143
stats.VersionsHitRate = float64(stats.VersionsCacheHits) / float64(stats.VersionsCalls)
@@ -131,10 +156,30 @@ func (c *CachedSource) GetCacheStats() CacheStats {
131156

132157
// ClearCache clears all cached data while preserving the underlying source.
133158
func (c *CachedSource) ClearCache() {
159+
c.mu.Lock()
134160
c.versionsCache = make(map[Name][]Version)
135161
c.depsCache = make(map[string][]Term)
136162
c.versionsCalls = 0
137163
c.versionsCacheHits = 0
138164
c.depsCalls = 0
139165
c.depsCacheHits = 0
166+
c.mu.Unlock()
167+
}
168+
169+
func cloneVersions(in []Version) []Version {
170+
if len(in) == 0 {
171+
return nil
172+
}
173+
out := make([]Version, len(in))
174+
copy(out, in)
175+
return out
176+
}
177+
178+
func cloneTerms(in []Term) []Term {
179+
if len(in) == 0 {
180+
return nil
181+
}
182+
out := make([]Term, len(in))
183+
copy(out, in)
184+
return out
140185
}

cached_source_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,66 @@ func TestCachedSource_Integration(t *testing.T) {
209209
t.Error("expected some calls to be made")
210210
}
211211
}
212+
213+
func TestCachedSource_ReturnsCopiesForVersions(t *testing.T) {
214+
inner := &InMemorySource{}
215+
v1 := SimpleVersion("1.0.0")
216+
v2 := SimpleVersion("2.0.0")
217+
pkg := MakeName("pkg")
218+
219+
inner.AddPackage(pkg, v1, nil)
220+
inner.AddPackage(pkg, v2, nil)
221+
222+
cached := NewCachedSource(inner)
223+
224+
versions, err := cached.GetVersions(pkg)
225+
if err != nil {
226+
t.Fatalf("unexpected error: %v", err)
227+
}
228+
if len(versions) != 2 {
229+
t.Fatalf("expected 2 versions, got %d", len(versions))
230+
}
231+
232+
versions[0] = SimpleVersion("9.9.9")
233+
234+
versions2, err := cached.GetVersions(pkg)
235+
if err != nil {
236+
t.Fatalf("unexpected error: %v", err)
237+
}
238+
239+
if versions2[0].String() == "9.9.9" {
240+
t.Fatalf("cached versions slice should not be mutable by callers")
241+
}
242+
}
243+
244+
func TestCachedSource_ReturnsCopiesForDependencies(t *testing.T) {
245+
inner := &InMemorySource{}
246+
pkg := MakeName("pkg")
247+
v1 := SimpleVersion("1.0.0")
248+
dep := MakeName("dep")
249+
250+
inner.AddPackage(pkg, v1, []Term{
251+
NewTerm(dep, EqualsCondition{Version: v1}),
252+
})
253+
254+
cached := NewCachedSource(inner)
255+
256+
deps, err := cached.GetDependencies(pkg, v1)
257+
if err != nil {
258+
t.Fatalf("unexpected error: %v", err)
259+
}
260+
if len(deps) != 1 {
261+
t.Fatalf("expected 1 dependency, got %d", len(deps))
262+
}
263+
264+
deps = append(deps, NewTerm(dep, EqualsCondition{Version: SimpleVersion("2.0.0")}))
265+
_ = deps
266+
267+
deps2, err := cached.GetDependencies(pkg, v1)
268+
if err != nil {
269+
t.Fatalf("unexpected error: %v", err)
270+
}
271+
if len(deps2) != 1 {
272+
t.Fatalf("cached dependencies slice should not be mutable by callers; got %d entries", len(deps2))
273+
}
274+
}

solver_options.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ type SolverOptions struct {
3737
// Logger enables debug logging of solver operations.
3838
// When nil, no logging is performed.
3939
Logger *slog.Logger
40+
41+
// PreferHighestVersions forces the solver to pick the highest allowed version.
42+
// When false, the solver uses a dependency-flexibility heuristic.
43+
PreferHighestVersions bool
4044
}
4145

4246
// SolverOption is a functional option for configuring the solver.
@@ -49,6 +53,7 @@ func defaultSolverOptions() SolverOptions {
4953
return SolverOptions{
5054
TrackIncompatibilities: false,
5155
MaxSteps: defaultMaxSteps,
56+
PreferHighestVersions: false,
5257
}
5358
}
5459

@@ -106,3 +111,11 @@ func WithLogger(logger *slog.Logger) SolverOption {
106111
opts.Logger = logger
107112
}
108113
}
114+
115+
// WithPreferHighestVersions forces the solver to choose the highest allowed version.
116+
// This matches Bundler-style "latest" resolution behavior.
117+
func WithPreferHighestVersions(enabled bool) SolverOption {
118+
return func(opts *SolverOptions) {
119+
opts.PreferHighestVersions = enabled
120+
}
121+
}

source_combined.go

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ type CombinedSource []Source
4040
// GetVersions queries all sources and returns the combined set of versions
4141
// in sorted order. Returns an error only if all sources fail with non-NotFound errors.
4242
func (s CombinedSource) GetVersions(name Name) ([]Version, error) {
43-
var ret []Version
43+
unique := make(map[string]Version)
44+
var firstErr error
4445
var sawNotFound bool
4546
for _, source := range s {
4647
versions, err := source.GetVersions(name)
@@ -50,29 +51,48 @@ func (s CombinedSource) GetVersions(name Name) ([]Version, error) {
5051
sawNotFound = true
5152
continue
5253
}
53-
return nil, err
54+
if firstErr == nil {
55+
firstErr = err
56+
}
57+
continue
58+
}
59+
for _, ver := range versions {
60+
key := ver.String()
61+
if _, ok := unique[key]; !ok {
62+
unique[key] = ver
63+
}
5464
}
55-
ret = append(ret, versions...)
5665
}
5766

58-
if len(ret) == 0 {
59-
if sawNotFound {
60-
return nil, &PackageNotFoundError{Package: name}
67+
if len(unique) > 0 {
68+
ret := make([]Version, 0, len(unique))
69+
for _, ver := range unique {
70+
ret = append(ret, ver)
6171
}
62-
return nil, &PackageNotFoundError{Package: name}
72+
73+
// sort the versions
74+
slices.SortFunc(ret, func(a Version, b Version) int {
75+
return a.Sort(b)
76+
})
77+
78+
return ret, nil
6379
}
6480

65-
// sort the versions
66-
slices.SortFunc(ret, func(a Version, b Version) int {
67-
return a.Sort(b)
68-
})
81+
if firstErr != nil {
82+
return nil, firstErr
83+
}
6984

70-
return ret, nil
85+
if sawNotFound {
86+
return nil, &PackageNotFoundError{Package: name}
87+
}
88+
89+
return nil, &PackageNotFoundError{Package: name}
7190
}
7291

7392
// GetDependencies queries sources in order and returns dependencies from the
7493
// first source that has the specified package version.
7594
func (s CombinedSource) GetDependencies(name Name, version Version) ([]Term, error) {
95+
var lastErr error
7696
for _, source := range s {
7797
deps, err := source.GetDependencies(name, version)
7898
if err != nil {
@@ -84,13 +104,20 @@ func (s CombinedSource) GetDependencies(name Name, version Version) ([]Term, err
84104
case errors.As(err, &verErr):
85105
continue
86106
default:
87-
return nil, err
107+
if lastErr == nil {
108+
lastErr = err
109+
}
110+
continue
88111
}
89112
} else {
90113
return deps, nil
91114
}
92115
}
93116

117+
if lastErr != nil {
118+
return nil, lastErr
119+
}
120+
94121
return nil, &PackageVersionNotFoundError{Package: name, Version: version}
95122
}
96123

0 commit comments

Comments
 (0)