github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/pkg/platform/runtime/artifactcache/artifactcache.go (about)

     1  package artifactcache
     2  
     3  import (
     4  	"encoding/json"
     5  	"os"
     6  	"path/filepath"
     7  	"strconv"
     8  	"sync"
     9  	"time"
    10  
    11  	"github.com/ActiveState/cli/internal/constants"
    12  	"github.com/ActiveState/cli/internal/errs"
    13  	"github.com/ActiveState/cli/internal/fileutils"
    14  	"github.com/ActiveState/cli/internal/installation/storage"
    15  	"github.com/ActiveState/cli/internal/logging"
    16  	"github.com/ActiveState/cli/internal/multilog"
    17  	"github.com/ActiveState/cli/internal/rollbar"
    18  	"github.com/go-openapi/strfmt"
    19  )
    20  
    21  type cachedArtifact struct {
    22  	Id             strfmt.UUID `json:"id"`
    23  	ArchivePath    string      `json:"archivePath"`
    24  	Size           int64       `json:"size"`
    25  	LastAccessTime int64       `json:"lastAccessTime"`
    26  }
    27  
    28  // ArtifactCache is a cache of downloaded artifacts from the ActiveState Platform.
    29  // The State Tool prefers to use this cache instead of redownloading artifacts.
    30  type ArtifactCache struct {
    31  	dir              string
    32  	infoJson         string
    33  	maxSize          int64 // bytes
    34  	currentSize      int64 // bytes
    35  	artifacts        map[strfmt.UUID]*cachedArtifact
    36  	mutex            sync.Mutex
    37  	timeSpentCopying time.Duration
    38  	sizeCopied       int64 // bytes
    39  }
    40  
    41  const MB int64 = 1024 * 1024
    42  
    43  // New returns a new artifact cache in the State Tool's cache directory with the default maximum size of 1GB.
    44  func New() (*ArtifactCache, error) {
    45  	var maxSize int64 = 1024 * MB
    46  	// TODO: size should be configurable and the user should be warned of an invalid size.
    47  	// https://activestatef.atlassian.net/browse/DX-984
    48  	if sizeOverride, err := strconv.Atoi(os.Getenv(constants.ArtifactCacheSizeEnvVarName)); err != nil && sizeOverride > 0 {
    49  		maxSize = int64(sizeOverride) * MB
    50  	}
    51  	return newWithDirAndSize(storage.ArtifactCacheDir(), maxSize)
    52  }
    53  
    54  func newWithDirAndSize(dir string, maxSize int64) (*ArtifactCache, error) {
    55  	err := fileutils.MkdirUnlessExists(dir)
    56  	if err != nil {
    57  		return nil, errs.Wrap(err, "Could not create artifact cache directory '%s'", dir)
    58  	}
    59  
    60  	if !fileutils.IsDir(dir) {
    61  		return nil, errs.New("'%s' is not a directory; cannot use as artifact cache", dir)
    62  	}
    63  
    64  	var artifacts []cachedArtifact
    65  	infoJson := filepath.Join(dir, constants.ArtifactCacheFileName)
    66  	if fileutils.FileExists(infoJson) {
    67  		data, err := fileutils.ReadFile(infoJson)
    68  		if err != nil {
    69  			return nil, errs.Wrap(err, "Could not read artifact cache's "+infoJson)
    70  		}
    71  		err = json.Unmarshal(data, &artifacts)
    72  		if err != nil {
    73  			return nil, errs.Wrap(err, "Unable to read cached artifacts from "+infoJson)
    74  		}
    75  	}
    76  
    77  	var currentSize int64 = 0
    78  	artifactMap := map[strfmt.UUID]*cachedArtifact{}
    79  	for _, artifact := range artifacts {
    80  		currentSize += artifact.Size
    81  		artifactMap[artifact.Id] = &cachedArtifact{artifact.Id, artifact.ArchivePath, artifact.Size, artifact.LastAccessTime}
    82  	}
    83  
    84  	logging.Debug("Opened artifact cache at '%s' containing %d artifacts occupying %.1f/%.1f MB", dir, len(artifactMap), float64(currentSize)/float64(MB), float64(maxSize)/float64(MB))
    85  	return &ArtifactCache{dir, infoJson, maxSize, currentSize, artifactMap, sync.Mutex{}, 0, 0}, nil
    86  }
    87  
    88  // Get returns the path to the cached artifact with the given id along with true if it exists.
    89  // Otherwise returns an empty string and false.
    90  // Updates the access timestamp if possible so that this artifact is not removed anytime soon.
    91  func (cache *ArtifactCache) Get(a strfmt.UUID) (string, bool) {
    92  	cache.mutex.Lock()
    93  	defer cache.mutex.Unlock()
    94  
    95  	if artifact, found := cache.artifacts[a]; found {
    96  		logging.Debug("Fetched cached artifact '%s' as '%s'; updating access time", string(a), artifact.ArchivePath)
    97  		artifact.LastAccessTime = time.Now().Unix()
    98  		return artifact.ArchivePath, true
    99  	}
   100  	return "", false
   101  }
   102  
   103  // Stores the given artifact in the cache.
   104  // If the cache is too small, removes the least-recently accessed artifacts to make room.
   105  func (cache *ArtifactCache) Store(a strfmt.UUID, archivePath string) error {
   106  	cache.mutex.Lock()
   107  	defer cache.mutex.Unlock()
   108  
   109  	// Replace an existing artifact in the cache.
   110  	// This would really only happen if a checksum validation fails for the cached artifact (e.g. due
   111  	// to a bad actor replacing it) and the artifact is silently re-downloaded from the platform.
   112  	if existingArtifact, found := cache.artifacts[a]; found {
   113  		path := existingArtifact.ArchivePath
   114  		logging.Debug("Replacing cached artifact '%s'", path)
   115  		if fileutils.TargetExists(path) {
   116  			err := os.Remove(path)
   117  			if err != nil {
   118  				return errs.Wrap(err, "Unable to overwrite existing artifact '%s'", path)
   119  			}
   120  		}
   121  		delete(cache.artifacts, existingArtifact.Id)
   122  		cache.currentSize -= existingArtifact.Size
   123  	}
   124  
   125  	stat, err := os.Stat(archivePath)
   126  	if err != nil {
   127  		return errs.Wrap(err, "Unable to stat artifact '%s'. Does it exist?", archivePath)
   128  	}
   129  	size := stat.Size()
   130  
   131  	if size > cache.maxSize {
   132  		logging.Debug("Cannot avoid exceeding cache size; not storing artifact")
   133  		rollbar.Error("Artifact '%s' is %.1fMB, which exceeds the cache size of %.1fMB", a, float64(size)/float64(MB), float64(cache.maxSize)/float64(MB))
   134  		return nil
   135  	}
   136  
   137  	for cache.currentSize+size > cache.maxSize {
   138  		logging.Debug("Storing artifact in cache would exceed cache size; finding least-recently accessed artifact")
   139  		var lastAccessed *cachedArtifact
   140  		for _, artifact := range cache.artifacts {
   141  			if lastAccessed == nil || artifact.LastAccessTime < lastAccessed.LastAccessTime {
   142  				lastAccessed = artifact
   143  			}
   144  		}
   145  
   146  		if lastAccessed == nil {
   147  			rollbar.Error("Cannot avoid exceeding cache size; not storing artifact.")
   148  			return nil // avoid infinite loop, but this really shouldn't happen...
   149  		}
   150  
   151  		logging.Debug("Removing cached artifact '%s' last accessed on %s", lastAccessed.ArchivePath, time.Unix(lastAccessed.LastAccessTime, 0).Format(time.UnixDate))
   152  		if fileutils.TargetExists(lastAccessed.ArchivePath) {
   153  			err := os.Remove(lastAccessed.ArchivePath)
   154  			if err != nil {
   155  				return errs.Wrap(err, "Unable to remove cached artifact '%s'", lastAccessed.ArchivePath)
   156  			}
   157  		}
   158  		delete(cache.artifacts, lastAccessed.Id)
   159  		cache.currentSize -= lastAccessed.Size
   160  	}
   161  
   162  	targetPath := filepath.Join(cache.dir, string(a))
   163  	startTime := time.Now()
   164  	err = fileutils.CopyFile(archivePath, targetPath)
   165  	cache.timeSpentCopying += time.Since(startTime)
   166  	cache.sizeCopied += size
   167  	if err != nil {
   168  		return errs.Wrap(err, "Unable to copy artifact '%s' into cache as '%s'", archivePath, targetPath)
   169  	}
   170  
   171  	logging.Debug("Storing artifact '%s'", targetPath)
   172  	cached := &cachedArtifact{a, targetPath, size, time.Now().Unix()}
   173  	cache.artifacts[a] = cached
   174  	cache.currentSize += size
   175  
   176  	return nil
   177  }
   178  
   179  // Saves this cache's information to disk.
   180  // You must call this function when you are done utilizing the cache.
   181  func (cache *ArtifactCache) Save() error {
   182  	artifacts := make([]*cachedArtifact, len(cache.artifacts))
   183  	i := 0
   184  	for _, artifact := range cache.artifacts {
   185  		artifacts[i] = artifact
   186  		i++
   187  	}
   188  	data, err := json.Marshal(artifacts)
   189  	if err != nil {
   190  		return errs.Wrap(err, "Unable to store cached artifacts into JSON")
   191  	}
   192  
   193  	logging.Debug("Saving artifact cache at '%s'", cache.infoJson)
   194  	err = fileutils.WriteFile(cache.infoJson, data)
   195  	if err != nil {
   196  		return errs.Wrap(err, "Unable to write artifact cache's "+cache.infoJson)
   197  	}
   198  
   199  	if cache.timeSpentCopying > 5*time.Second {
   200  		multilog.Log(logging.Debug, rollbar.Error)("Spent %.1f seconds copying %.1fMB of artifacts to cache", cache.timeSpentCopying.Seconds(), float64(cache.sizeCopied)/float64(MB))
   201  	}
   202  	cache.timeSpentCopying = 0 // reset
   203  	cache.sizeCopied = 0       // reset
   204  
   205  	return nil
   206  }