Skip to content

Commit 9fdd8bd

Browse files
authored
feat: Go versions info disk cache (#455)
* feat: persist play.go.dev Go versions * feat: add tests * feat: compute fallback Go versions
1 parent 3f64432 commit 9fdd8bd

File tree

8 files changed

+574
-178
lines changed

8 files changed

+574
-178
lines changed

cmd/playground/main.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/x1unix/go-playground/internal/builder/storage"
1616
"github.com/x1unix/go-playground/internal/config"
1717
"github.com/x1unix/go-playground/internal/server"
18+
"github.com/x1unix/go-playground/internal/server/backendinfo"
1819
"github.com/x1unix/go-playground/internal/server/webutil"
1920
"github.com/x1unix/go-playground/pkg/goplay"
2021
"github.com/x1unix/go-playground/pkg/util/cmdutil"
@@ -77,11 +78,16 @@ func start(logger *zap.Logger, cfg *config.Config) error {
7778
go cleanupSvc.Start(ctx)
7879
}
7980

81+
backendsInfoSvc := backendinfo.NewBackendVersionService(zap.L(), playgroundClient, backendinfo.ServiceConfig{
82+
CacheFile: filepath.Join(cfg.Build.BuildDir, "go-versions.json"),
83+
TTL: backendinfo.DefaultVersionCacheTTL,
84+
})
85+
8086
// Initialize API endpoints
8187
r := mux.NewRouter()
8288
apiRouter := r.PathPrefix("/api").Subrouter()
8389
svcCfg := server.ServiceConfig{Version: Version}
84-
server.NewAPIv1Handler(svcCfg, playgroundClient, buildSvc).
90+
server.NewAPIv1Handler(svcCfg, playgroundClient, buildSvc, backendsInfoSvc).
8591
Mount(apiRouter)
8692

8793
apiv2Router := apiRouter.PathPrefix("/v2").Subrouter()
@@ -90,7 +96,6 @@ func start(logger *zap.Logger, cfg *config.Config) error {
9096
Builder: buildSvc,
9197
BuildTimeout: cfg.Build.GoBuildTimeout,
9298
}).Mount(apiv2Router)
93-
//server.NewAPIv2Handler(playgroundClient, buildSvc).Mount(apiv2Router)
9499

95100
// Web UI routes
96101
tplVars := server.TemplateArguments{
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package backendinfo
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
)
7+
8+
func prefillFallbacks(info *BackendVersions) {
9+
if info.PreviousStable == "" {
10+
info.PreviousStable = guessPreviousVersion(info.CurrentStable)
11+
}
12+
13+
if info.Nightly == "" {
14+
info.Nightly = "devel"
15+
}
16+
}
17+
18+
func guessPreviousVersion(baseVer string) string {
19+
chunks := strings.Split(baseVer, ".")
20+
if len(chunks) < 2 {
21+
return baseVer
22+
}
23+
24+
minorVer, err := strconv.Atoi(chunks[1])
25+
if err != nil {
26+
return baseVer
27+
}
28+
29+
minorVer = max(0, minorVer-1)
30+
return chunks[0] + "." + strconv.Itoa(minorVer) + ".0"
31+
}
32+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package backendinfo
2+
3+
import "context"
4+
5+
type BackendVersions struct {
6+
// CurrentStable is latest stable Go version.
7+
CurrentStable string
8+
9+
// PreviousStable is previous stable Go version.
10+
PreviousStable string
11+
12+
// Nightly is latest unstable Go version (tip) version.
13+
Nightly string
14+
}
15+
16+
type BackendVersionProvider interface {
17+
// GetRemoteVersions returns Go version used on remote Go backends.
18+
GetRemoteVersions(ctx context.Context) (*BackendVersions, error)
19+
20+
// ServerVersion returns Go version used on server.
21+
ServerVersion() string
22+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
package backendinfo
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io/fs"
10+
"os"
11+
"path/filepath"
12+
"runtime"
13+
"strings"
14+
"time"
15+
16+
"github.com/avast/retry-go"
17+
"github.com/x1unix/go-playground/pkg/goplay"
18+
"go.uber.org/zap"
19+
"golang.org/x/sync/errgroup"
20+
)
21+
22+
const (
23+
goVersionRetryAttempts = 3
24+
goVersionRetryDelay = time.Second
25+
26+
DefaultVersionCacheTTL = 48 * time.Hour
27+
)
28+
29+
//go:embed resources/version.go.txt
30+
var versionSnippet []byte
31+
32+
const cacheFileVersion = 1
33+
34+
var _ BackendVersionProvider = (*BackendVersionService)(nil)
35+
36+
type ServiceConfig struct {
37+
// Version is cache file version
38+
Version int
39+
40+
// CacheFile is name of a file which will be used to cache Go playground versions.
41+
CacheFile string
42+
43+
// TTL is expiration interval.
44+
TTL time.Duration
45+
}
46+
47+
type cacheEntry struct {
48+
Version int
49+
CreatedAt time.Time
50+
Data BackendVersions
51+
}
52+
53+
// BackendVersionService provides information about used Go versions
54+
// for all backends.
55+
type BackendVersionService struct {
56+
logger *zap.Logger
57+
client *goplay.Client
58+
cfg ServiceConfig
59+
60+
memCache *cacheEntry
61+
}
62+
63+
func NewBackendVersionService(logger *zap.Logger, client *goplay.Client, cfg ServiceConfig) *BackendVersionService {
64+
return &BackendVersionService{
65+
logger: logger,
66+
client: client,
67+
cfg: cfg,
68+
}
69+
}
70+
71+
func (svc *BackendVersionService) ServerVersion() string {
72+
return normalizeGoVersion(runtime.Version())
73+
}
74+
75+
func (svc *BackendVersionService) visitCache() (*cacheEntry, error) {
76+
if svc.memCache != nil {
77+
return svc.memCache, nil
78+
}
79+
80+
if svc.cfg.CacheFile == "" {
81+
return nil, fs.ErrNotExist
82+
}
83+
84+
f, err := os.Open(svc.cfg.CacheFile)
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
defer f.Close()
90+
dst := &cacheEntry{}
91+
err = json.NewDecoder(f).Decode(dst)
92+
93+
return dst, err
94+
}
95+
96+
// GetVersions provides Go version information for all backends.
97+
func (svc *BackendVersionService) GetRemoteVersions(ctx context.Context) (*BackendVersions, error) {
98+
cached, err := svc.visitCache()
99+
if err != nil {
100+
if !errors.Is(err, fs.ErrNotExist) {
101+
svc.logger.Error("failed to check Go versions cache", zap.Error(err))
102+
}
103+
104+
return svc.populateVersionCache(ctx)
105+
}
106+
107+
if cached.Version != cacheFileVersion {
108+
return nil, fs.ErrNotExist
109+
}
110+
111+
dt := time.Now().UTC().Sub(cached.CreatedAt.UTC())
112+
if dt >= svc.cfg.TTL {
113+
return svc.populateVersionCache(ctx)
114+
}
115+
116+
return &cached.Data, nil
117+
}
118+
119+
func (svc *BackendVersionService) populateVersionCache(ctx context.Context) (*BackendVersions, error) {
120+
versions, err := svc.pullBackendVersions(ctx)
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
if err := svc.cacheVersions(versions); err != nil {
126+
svc.logger.Error("failed to cache Go versions", zap.Error(err))
127+
}
128+
129+
return versions, nil
130+
}
131+
132+
func (svc *BackendVersionService) cacheVersions(versions *BackendVersions) error {
133+
svc.memCache = &cacheEntry{
134+
Version: cacheFileVersion,
135+
CreatedAt: time.Now().UTC(),
136+
Data: *versions,
137+
}
138+
139+
if svc.cfg.CacheFile == "" {
140+
return nil
141+
}
142+
143+
err := os.MkdirAll(filepath.Dir(svc.cfg.CacheFile), 0755)
144+
if err != nil {
145+
return fmt.Errorf("MkdirAll failed: %w", err)
146+
}
147+
148+
f, err := os.OpenFile(svc.cfg.CacheFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
149+
if err != nil {
150+
return err
151+
}
152+
153+
defer f.Close()
154+
return json.NewEncoder(f).Encode(svc.memCache)
155+
}
156+
157+
func (svc *BackendVersionService) pullBackendVersions(ctx context.Context) (*BackendVersions, error) {
158+
versionInfo := &BackendVersions{}
159+
g, gCtx := errgroup.WithContext(ctx)
160+
161+
mapping := [3]struct {
162+
backend string
163+
dst *string
164+
}{
165+
{
166+
backend: goplay.BackendGoCurrent,
167+
dst: &versionInfo.CurrentStable,
168+
},
169+
{
170+
backend: goplay.BackendGoPrev,
171+
dst: &versionInfo.PreviousStable,
172+
},
173+
{
174+
backend: goplay.BackendGoTip,
175+
dst: &versionInfo.Nightly,
176+
},
177+
}
178+
179+
for _, e := range mapping {
180+
b := e
181+
g.Go(func() error {
182+
svc.logger.Debug("Fetching go version for backend", zap.String("backend", e.backend))
183+
result, err := svc.fetchGoBackendVersionWithRetry(gCtx, e.backend)
184+
if err != nil {
185+
// Playground "gotip" and "goprev" backends are often broken
186+
// and I'm getting tired of seeing 5xx responses if just one of them is dead.
187+
//
188+
// Throw only if stable version is down. For others - try to figure out fallback values.
189+
if e.backend == goplay.BackendGoCurrent {
190+
return fmt.Errorf("failed to get Go version from Go playground server for backend %q: %w",
191+
b.backend, err)
192+
}
193+
194+
svc.logger.Warn(
195+
"can't fetch Go version for backend, will use fallback",
196+
zap.String("backend", e.backend), zap.Error(err),
197+
)
198+
return nil
199+
}
200+
201+
// We don't afraid race condition because each backend is written to a separate address
202+
*b.dst = result
203+
return nil
204+
})
205+
}
206+
207+
if err := g.Wait(); err != nil {
208+
return nil, err
209+
}
210+
211+
prefillFallbacks(versionInfo)
212+
return versionInfo, nil
213+
}
214+
215+
func (svc *BackendVersionService) fetchGoBackendVersionWithRetry(ctx context.Context, backend goplay.Backend) (string, error) {
216+
var result string
217+
err := retry.Do(
218+
func() error {
219+
version, err := svc.getGoBackendVersion(ctx, backend)
220+
if err != nil {
221+
return err
222+
}
223+
224+
result = version
225+
return nil
226+
},
227+
retry.Attempts(goVersionRetryAttempts),
228+
retry.Delay(goVersionRetryDelay),
229+
retry.RetryIf(func(err error) bool {
230+
httpErr, ok := goplay.IsHTTPError(err)
231+
if !ok {
232+
return false
233+
}
234+
235+
// Retry only on server issues
236+
return httpErr.StatusCode >= 500
237+
}),
238+
retry.OnRetry(func(n uint, err error) {
239+
svc.logger.Error("failed to get Go version from Go playground, retrying...",
240+
zap.Error(err), zap.String("backend", backend), zap.Uint("attempt", n))
241+
}),
242+
)
243+
244+
return result, err
245+
}
246+
247+
func (svc *BackendVersionService) getGoBackendVersion(ctx context.Context, backend goplay.Backend) (string, error) {
248+
// Dirty hack to fetch Go version for playground backend by running a simple program
249+
// which returns Go version to stdout.
250+
result, err := svc.client.Evaluate(ctx, goplay.CompileRequest{
251+
Version: goplay.DefaultVersion,
252+
WithVet: false,
253+
Body: versionSnippet,
254+
}, backend)
255+
256+
if err != nil {
257+
return "", err
258+
}
259+
260+
if result.Errors != "" {
261+
return "", fmt.Errorf("probe program returned an error: %s", result.Errors)
262+
}
263+
264+
if len(result.Events) == 0 {
265+
return "", errors.New("missing output events from probe program")
266+
}
267+
268+
version := normalizeGoVersion(result.Events[0].Message)
269+
return version, nil
270+
}
271+
272+
func normalizeGoVersion(str string) string {
273+
return strings.TrimPrefix(str, "go")
274+
}

0 commit comments

Comments
 (0)