mirror of
https://github.com/gomods/athens
synced 2026-02-03 11:00:32 +00:00
* gcp/saver: Only return errors.KindAlreadyExists if all three exist In #1124, a GCP lock type was added as a singleflight backend. As part of this work, the GCP backend's Save() was made serial, likely because moduploader.Upload requires a call to Exists() before it, rendering the GCP lock less useful, by doubling the calls to GCS. However, by doing this, the existence check was now only checking the existence of the mod file, and not the info or zip. This meant that if during a Save, the zip or info uploads failed, on subsequent rquests, that when using the GCP singleflight backend, Athens would assume everything had been stashed and saved properly, and then fail to serve up the info or zip that had failed upload, meaning the cache was in an unhealable broklen state, requiring a manual intervention. To fix this, without breaking the singleflight behavior, introduce a metadata key that is set on the mod file during its initial upload, indicating that a Stash is still in progress on subsequent files, which gets removed once all three files are uploaded successfully, which can be checked if it it is determined that the mod file already exists. That way we can return a errors.KindAlreadyExists if a Stash is in progress, but also properly return it when a Stash is *not* currently in progress if and only if all three files exist on GCS, which prevents the cache from becoming permanently poisoned. One note is that it is possible the GCS call to remove the metadata key fails, which would mean it is left on the mod object forever. To avoid this, consider it stale after 2 minutes. --------- Signed-off-by: Derek Buitenhuis <derek.buitenhuis@gmail.com> Co-authored-by: Matt <matt.ouille@protonmail.com>
205 lines
4.8 KiB
Go
205 lines
4.8 KiB
Go
package stash
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gomods/athens/pkg/config"
|
|
"github.com/gomods/athens/pkg/errors"
|
|
"github.com/gomods/athens/pkg/storage"
|
|
"github.com/gomods/athens/pkg/storage/gcp"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type failReader int
|
|
|
|
func (f *failReader) Read([]byte) (int, error) {
|
|
return 0, fmt.Errorf("failure")
|
|
}
|
|
|
|
// TestWithGCS requires a real GCP backend implementation
|
|
// and it will ensure that saving to modules at the same time
|
|
// is done synchronously so that only the first module gets saved.
|
|
func TestWithGCS(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
|
|
defer cancel()
|
|
const (
|
|
mod = "stashmod"
|
|
ver = "v1.0.0"
|
|
)
|
|
strg := getStorage(t)
|
|
strg.Delete(ctx, mod, ver)
|
|
defer strg.Delete(ctx, mod, ver)
|
|
|
|
// sanity check
|
|
_, err := strg.GoMod(ctx, mod, ver)
|
|
if !errors.Is(err, errors.KindNotFound) {
|
|
t.Fatalf("expected the stash bucket to return a NotFound error but got: %v", err)
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
for i := 0; i < 5; i++ {
|
|
content := uuid.New().String()
|
|
ms := &mockGCPStasher{strg, content}
|
|
gs, err := WithGCSLock(120, strg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := gs(ms)
|
|
eg.Go(func() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
|
defer cancel()
|
|
_, err := s.Stash(ctx, "stashmod", "v1.0.0")
|
|
return err
|
|
})
|
|
}
|
|
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := strg.Info(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
modContent, err := strg.GoMod(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zip, err := strg.Zip(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer zip.Close()
|
|
zipContent, err := io.ReadAll(zip)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(info, modContent) {
|
|
t.Fatalf("expected info and go.mod to be equal but info was {%v} and content was {%v}", string(info), string(modContent))
|
|
}
|
|
if !bytes.Equal(info, zipContent) {
|
|
t.Fatalf("expected info and zip to be equal but info was {%v} and content was {%v}", string(info), string(zipContent))
|
|
}
|
|
}
|
|
|
|
// TestWithGCSPartialFailure equires a real GCP backend implementation
|
|
// and ensures that if one of the non-singleflight-lock files fails to
|
|
// upload, that the cache does not remain poisoned.
|
|
func TestWithGCSPartialFailure(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
|
|
defer cancel()
|
|
const (
|
|
mod = "stashmod"
|
|
ver = "v1.0.0"
|
|
)
|
|
strg := getStorage(t)
|
|
strg.Delete(ctx, mod, ver)
|
|
defer strg.Delete(ctx, mod, ver)
|
|
|
|
// sanity check
|
|
_, err := strg.GoMod(ctx, mod, ver)
|
|
if !errors.Is(err, errors.KindNotFound) {
|
|
t.Fatalf("expected the stash bucket to return a NotFound error but got: %v", err)
|
|
}
|
|
|
|
content := uuid.New().String()
|
|
ms := &mockGCPStasher{strg, content}
|
|
fr := new(failReader)
|
|
gs, err := WithGCSLock(120, strg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := gs(ms)
|
|
// We simulate a failure by manually passing an io.Reader that will fail.
|
|
err = ms.strg.Save(ctx, "stashmod", "v1.0.0", []byte(ms.content), fr, []byte(ms.content))
|
|
if err == nil {
|
|
// We *want* to fail.
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Now try a Stash. This should upload the missing files.
|
|
_, err = s.Stash(ctx, "stashmod", "v1.0.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := strg.Info(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
modContent, err := strg.GoMod(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zip, err := strg.Zip(ctx, mod, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer zip.Close()
|
|
zipContent, err := io.ReadAll(zip)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(info, modContent) {
|
|
t.Fatalf("expected info and go.mod to be equal but info was {%v} and content was {%v}", string(info), string(modContent))
|
|
}
|
|
if !bytes.Equal(info, zipContent) {
|
|
t.Fatalf("expected info and zip to be equal but info was {%v} and content was {%v}", string(info), string(zipContent))
|
|
}
|
|
}
|
|
|
|
// mockGCPStasher is like mockStasher
|
|
// but leverages in memory storage
|
|
// so that redis can determine
|
|
// whether to call the underlying stasher or not.
|
|
type mockGCPStasher struct {
|
|
strg storage.Backend
|
|
content string
|
|
}
|
|
|
|
func (ms *mockGCPStasher) Stash(ctx context.Context, mod, ver string) (string, error) {
|
|
err := ms.strg.Save(
|
|
ctx,
|
|
mod,
|
|
ver,
|
|
[]byte(ms.content),
|
|
strings.NewReader(ms.content),
|
|
[]byte(ms.content),
|
|
)
|
|
return "", err
|
|
}
|
|
|
|
func getStorage(t *testing.T) *gcp.Storage {
|
|
t.Helper()
|
|
cfg := getTestConfig()
|
|
if cfg == nil {
|
|
t.SkipNow()
|
|
}
|
|
|
|
s, err := gcp.New(context.Background(), cfg, config.GetTimeoutDuration(30))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func getTestConfig() *config.GCPConfig {
|
|
creds := os.Getenv("GCS_SERVICE_ACCOUNT")
|
|
if creds == "" {
|
|
return nil
|
|
}
|
|
return &config.GCPConfig{
|
|
Bucket: "athens_drone_stash_bucket",
|
|
JSONKey: creds,
|
|
}
|
|
}
|