go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/data/caching/cache/cache.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cache
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"crypto"
    21  	"encoding/json"
    22  	"flag"
    23  	"fmt"
    24  	"io"
    25  	"io/ioutil"
    26  	"os"
    27  	"path/filepath"
    28  	"runtime"
    29  	"runtime/trace"
    30  	"sync"
    31  	"time"
    32  
    33  	"go.chromium.org/luci/common/data/text/units"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/logging"
    36  	"go.chromium.org/luci/common/system/filesystem"
    37  )
    38  
    39  // Cache is a cache of objects holding content in disk.
    40  //
    41  // All implementations must be thread-safe.
    42  type Cache struct {
    43  	// Immutable.
    44  	policies Policies
    45  	path     string
    46  	h        crypto.Hash
    47  
    48  	freeSpaceWarningOnce sync.Once
    49  
    50  	// Lock protected.
    51  	mu  sync.Mutex // This protects modification of cached entries under |path| too.
    52  	lru lruDict    // Implements LRU based eviction.
    53  
    54  	// TODO(crbug.com/1231726): remove after debug.
    55  	log bytes.Buffer
    56  
    57  	statsMu sync.Mutex // Protects the stats below
    58  	// TODO(tikuta): Add stats about: # removed.
    59  	// TODO(tikuta): stateFile
    60  	added []int64
    61  	used  []int64
    62  }
    63  
    64  // Policies is the policies to use on a cache to limit it's footprint.
    65  //
    66  // It's a cache, not a leak.
    67  type Policies struct {
    68  	// MaxSize trims if the cache gets larger than this value. If 0, the cache is
    69  	// effectively a leak.
    70  	MaxSize units.Size
    71  	// MaxItems is the maximum number of items to keep in the cache. If 0, do not
    72  	// enforce a limit.
    73  	MaxItems int
    74  	// MinFreeSpace trims if disk free space becomes lower than this value.
    75  	// Only makes sense when using disk based cache.
    76  	MinFreeSpace units.Size
    77  }
    78  
    79  // AddFlags adds flags for cache policy parameters.
    80  func (p *Policies) AddFlags(f *flag.FlagSet) {
    81  	f.Var(&p.MaxSize, "cache-max-size", "Cache is trimmed if the cache gets larger than this value. If 0, the cache is effectively a leak.")
    82  	f.IntVar(&p.MaxItems, "cache-max-items", 0, "Maximum number of items to keep in the cache.")
    83  	f.Var(&p.MinFreeSpace, "cache-min-free-space", "Cache is trimmed if disk free space becomes lower than this value.")
    84  }
    85  
    86  // IsDefault returns whether some flags are set or not.
    87  func (p *Policies) IsDefault() bool {
    88  	return p.MaxSize == 0 && p.MaxItems == 0 && p.MinFreeSpace == 0
    89  }
    90  
    91  func (p *Policies) fitsCacheSize(s units.Size) bool {
    92  	return p.MaxSize == 0 || s <= p.MaxSize
    93  }
    94  
    95  // ErrInvalidHash indicates invalid hash is specified.
    96  var ErrInvalidHash = errors.New("invalid hash")
    97  
    98  // New creates a disk based cache.
    99  //
   100  // It may return both a valid Cache and an error if it failed to load the
   101  // previous cache metadata. It is safe to ignore this error. This creates
   102  // cache directory if it doesn't exist.
   103  func New(policies Policies, path string, h crypto.Hash) (*Cache, error) {
   104  	var err error
   105  	path, err = filepath.Abs(path)
   106  	if err != nil {
   107  		return nil, errors.Annotate(err, "failed to call Abs(%s)", path).Err()
   108  	}
   109  	err = os.MkdirAll(path, 0700)
   110  	if err != nil {
   111  		return nil, errors.Annotate(err, "failed to call MkdirAll(%s)", path).Err()
   112  	}
   113  
   114  	d := &Cache{
   115  		policies: policies,
   116  		path:     path,
   117  		h:        h,
   118  		lru:      makeLRUDict(h),
   119  	}
   120  	p := d.statePath()
   121  
   122  	err = func() error {
   123  		f, err := os.Open(p)
   124  		if err != nil && os.IsNotExist(err) {
   125  			// The fact that the cache is new is not an error.
   126  			return nil
   127  		}
   128  		if err != nil {
   129  			return err
   130  		}
   131  		defer f.Close()
   132  		return json.NewDecoder(f).Decode(&d.lru)
   133  	}()
   134  
   135  	if err != nil {
   136  		// Do not use os.RemoveAll, due to strange 'Access Denied' error on windows
   137  		// in os.MkDir after os.RemoveAll.
   138  		// crbug.com/932396#c123
   139  		files, err := os.ReadDir(path)
   140  		if err != nil {
   141  			return nil, errors.Annotate(err, "failed to call os.ReadDir(%s)", path).Err()
   142  		}
   143  
   144  		for _, file := range files {
   145  			p := filepath.Join(path, file.Name())
   146  			if err := os.RemoveAll(p); err != nil {
   147  				return nil, errors.Annotate(err, "failed to call os.RemoveAll(%s)", p).Err()
   148  			}
   149  		}
   150  
   151  		d.lru = makeLRUDict(h)
   152  	}
   153  
   154  	if json, err := d.lru.MarshalJSON(); err != nil {
   155  		return nil, err
   156  	} else {
   157  		fmt.Fprintf(&d.log, "initial json: %s\n", string(json))
   158  	}
   159  
   160  	return d, err
   161  }
   162  
   163  // Close closes the Cache, writes the cache status file to cache dir.
   164  func (d *Cache) Close() error {
   165  	d.mu.Lock()
   166  	defer d.mu.Unlock()
   167  	if !d.lru.IsDirty() {
   168  		return nil
   169  	}
   170  	f, err := os.Create(d.statePath())
   171  	if err == nil {
   172  		defer f.Close()
   173  		err = json.NewEncoder(f).Encode(&d.lru)
   174  	}
   175  	return err
   176  }
   177  
   178  // Keys returns the list of all cached digests in LRU order.
   179  func (d *Cache) Keys() HexDigests {
   180  	d.mu.Lock()
   181  	defer d.mu.Unlock()
   182  	return d.lru.keys()
   183  }
   184  
   185  // TotalSize returns the size of the contents maintained in the LRU cache.
   186  func (d *Cache) TotalSize() units.Size {
   187  	d.mu.Lock()
   188  	defer d.mu.Unlock()
   189  	return d.lru.sum
   190  }
   191  
   192  // Touch updates the LRU position of an item to ensure it is kept in the
   193  // cache.
   194  //
   195  // Returns true if item is in cache.
   196  func (d *Cache) Touch(digest HexDigest) bool {
   197  	if !digest.Validate(d.h) {
   198  		return false
   199  	}
   200  	d.mu.Lock()
   201  	defer d.mu.Unlock()
   202  	return d.lru.touch(digest)
   203  }
   204  
   205  // Evict removes item from cache if it's there.
   206  func (d *Cache) Evict(digest HexDigest) {
   207  	if !digest.Validate(d.h) {
   208  		return
   209  	}
   210  	d.mu.Lock()
   211  	defer d.mu.Unlock()
   212  	d.lru.pop(digest)
   213  	_ = os.Remove(d.itemPath(digest))
   214  }
   215  
   216  // Read returns contents of the cached item.
   217  func (d *Cache) Read(digest HexDigest) (io.ReadCloser, error) {
   218  	if !digest.Validate(d.h) {
   219  		return nil, os.ErrInvalid
   220  	}
   221  
   222  	d.mu.Lock()
   223  	f, err := os.Open(d.itemPath(digest))
   224  	if err != nil {
   225  		d.mu.Unlock()
   226  		return nil, err
   227  	}
   228  	d.lru.touch(digest)
   229  	d.mu.Unlock()
   230  
   231  	fi, err := f.Stat()
   232  	if err != nil {
   233  		f.Close()
   234  		return nil, errors.Annotate(err, "failed to get stat for %s", digest).Err()
   235  	}
   236  
   237  	d.statsMu.Lock()
   238  	defer d.statsMu.Unlock()
   239  	d.used = append(d.used, fi.Size())
   240  	return f, nil
   241  }
   242  
   243  // Add reads data from src and stores it in cache.
   244  func (d *Cache) Add(ctx context.Context, digest HexDigest, src io.Reader) error {
   245  	return d.add(ctx, digest, src, nil)
   246  }
   247  
   248  // AddFileWithoutValidation adds src as cache entry with hardlink.
   249  // But this doesn't do any content validation.
   250  //
   251  // TODO(tikuta): make one function and control the behavior by option?
   252  func (d *Cache) AddFileWithoutValidation(ctx context.Context, digest HexDigest, src string) error {
   253  	ctx, task := trace.NewTask(ctx, "AddFileWithoutValidation")
   254  	defer task.End()
   255  
   256  	fi, err := os.Stat(src)
   257  	if err != nil {
   258  		return errors.Annotate(err, "failed to get stat: %s", src).Err()
   259  	}
   260  
   261  	d.mu.Lock()
   262  	defer d.mu.Unlock()
   263  	start := time.Now()
   264  	dest := d.itemPath(digest)
   265  	if err := makeHardLinkOrClone(src, dest); err != nil && !errors.Contains(err, os.ErrExist) {
   266  		terr := func() error {
   267  			if runtime.GOOS == "darwin" {
   268  				// TODO(crbug.com/1140864): Fallback to Copy in macOS, this is mitigation for strange `operation not permitted` error.
   269  				if cerr := filesystem.Copy(dest, src, fi.Mode()); cerr != nil {
   270  					err = errors.Annotate(err, "fallback copy failed: %v", cerr).Err()
   271  				} else {
   272  					return nil
   273  				}
   274  			}
   275  
   276  			return errors.Annotate(err, "failed to link %s to %s", src, digest).Err()
   277  		}()
   278  		if terr != nil {
   279  			return terr
   280  		}
   281  	}
   282  
   283  	trace.Logf(ctx, "", "os.Link took %s", time.Since(start))
   284  
   285  	d.lru.pushFront(digest, units.Size(fi.Size()))
   286  	if err := d.respectPolicies(ctx); err != nil {
   287  		d.lru.pop(digest)
   288  		return err
   289  	}
   290  
   291  	d.statsMu.Lock()
   292  	defer d.statsMu.Unlock()
   293  	d.added = append(d.added, fi.Size())
   294  	return nil
   295  }
   296  
   297  // AddWithHardlink reads data from src and stores it in cache and hardlink file.
   298  // This is to avoid file removal by shrink in Add().
   299  func (d *Cache) AddWithHardlink(ctx context.Context, digest HexDigest, src io.Reader, dest string, perm os.FileMode) error {
   300  	return d.add(ctx, digest, src, func() error {
   301  		if err := d.hardlinkUnlocked(digest, dest, perm); err != nil {
   302  			_ = os.Remove(d.itemPath(digest))
   303  			return errors.Annotate(err, "failed to call Hardlink(%s, %s)", digest, dest).Err()
   304  		}
   305  		return nil
   306  	})
   307  }
   308  
   309  // Hardlink ensures file at |dest| has the same content as cached |digest|.
   310  //
   311  // Note that the behavior when dest already exists is undefined. It will work
   312  // on all POSIX and may or may not fail on Windows depending on the
   313  // implementation used. Do not rely on this behavior.
   314  func (d *Cache) Hardlink(digest HexDigest, dest string, perm os.FileMode) error {
   315  	if runtime.GOOS == "darwin" {
   316  		// Accessing the path, which is being replaced, with os.Link
   317  		// seems to cause flaky 'operation not permitted' failure on
   318  		// macOS (https://crbug.com/1076468). So prevent that by holding
   319  		// lock here.
   320  		d.mu.Lock()
   321  		defer d.mu.Unlock()
   322  	}
   323  	return d.hardlinkUnlocked(digest, dest, perm)
   324  }
   325  
   326  // Added returns a list of file size added to cache.
   327  func (d *Cache) Added() []int64 {
   328  	d.statsMu.Lock()
   329  	defer d.statsMu.Unlock()
   330  	return append([]int64{}, d.added...)
   331  }
   332  
   333  // Used returns a list of file size used from cache.
   334  func (d *Cache) Used() []int64 {
   335  	d.statsMu.Lock()
   336  	defer d.statsMu.Unlock()
   337  	return append([]int64{}, d.used...)
   338  }
   339  
   340  // Private details.
   341  
   342  func (d *Cache) add(ctx context.Context, digest HexDigest, src io.Reader, cb func() error) error {
   343  	if !digest.Validate(d.h) {
   344  		return os.ErrInvalid
   345  	}
   346  	tmp, err := ioutil.TempFile(d.path, string(digest)+".*.tmp")
   347  	if err != nil {
   348  		return errors.Annotate(err, "failed to create tempfile for %s", digest).Err()
   349  	}
   350  	// TODO(maruel): Use a LimitedReader flavor that fails when reaching limit.
   351  	h := d.h.New()
   352  	size, err := io.Copy(tmp, io.TeeReader(src, h))
   353  	if err2 := tmp.Close(); err == nil {
   354  		err = err2
   355  	}
   356  	fname := tmp.Name()
   357  	if err != nil {
   358  		_ = os.Remove(fname)
   359  		return err
   360  	}
   361  	if d := Sum(h); d != digest {
   362  		_ = os.Remove(fname)
   363  		return errors.Annotate(ErrInvalidHash, "invalid hash, got=%s, want=%s", d, digest).Err()
   364  	}
   365  	if !d.policies.fitsCacheSize(units.Size(size)) {
   366  		_ = os.Remove(fname)
   367  		return errors.Reason("item too large, size=%d, limit=%d", size, d.policies.MaxSize).Err()
   368  	}
   369  
   370  	d.mu.Lock()
   371  	defer d.mu.Unlock()
   372  
   373  	// If the cache already exists, do not try os.Rename().
   374  	if d.lru.touch(digest) {
   375  		logging.Debugf(ctx, "cache already exists. path: %s, digest %s\n", d.path, digest)
   376  		if err := os.Remove(fname); err != nil {
   377  			return errors.Annotate(err, "failed to remove tmp file: %s", fname).Err()
   378  		}
   379  		if cb != nil {
   380  			if err := cb(); err != nil {
   381  				return err
   382  			}
   383  		}
   384  		return nil
   385  	}
   386  
   387  	if err := os.Rename(fname, d.itemPath(digest)); err != nil {
   388  		_ = os.Remove(fname)
   389  		return errors.Annotate(err, "failed to rename %s -> %s", fname, d.itemPath(digest)).Err()
   390  	}
   391  
   392  	if cb != nil {
   393  		if err := cb(); err != nil {
   394  			return err
   395  		}
   396  	}
   397  
   398  	d.lru.pushFront(digest, units.Size(size))
   399  	if err := d.respectPolicies(ctx); err != nil {
   400  		d.lru.pop(digest)
   401  		return err
   402  	}
   403  	d.statsMu.Lock()
   404  	defer d.statsMu.Unlock()
   405  	d.added = append(d.added, size)
   406  	return nil
   407  }
   408  
   409  func (d *Cache) hardlinkUnlocked(digest HexDigest, dest string, perm os.FileMode) error {
   410  	if !digest.Validate(d.h) {
   411  		return os.ErrInvalid
   412  	}
   413  	src := d.itemPath(digest)
   414  	// - Windows, if dest exists, the call fails. In particular, trying to
   415  	//   os.Remove() will fail if the file's ReadOnly bit is set. What's worse is
   416  	//   that the ReadOnly bit is set on the file inode, shared on all hardlinks
   417  	//   to this inode. This means that in the case of a file with the ReadOnly
   418  	//   bit set, it would have to do:
   419  	//   - If dest exists:
   420  	//    - If dest has ReadOnly bit:
   421  	//      - If file has any other inode:
   422  	//        - Remove the ReadOnly bit.
   423  	//        - Remove dest.
   424  	//        - Set the ReadOnly bit on one of the inode found.
   425  	//   - Call os.Link()
   426  	//  In short, nobody ain't got time for that.
   427  	//
   428  	// - On any other (sane) OS, if dest exists, it is silently overwritten.
   429  	if err := makeHardLinkOrClone(src, dest); err != nil {
   430  		if _, serr := os.Stat(src); errors.Contains(serr, os.ErrNotExist) {
   431  			// In Windows, os.Link may fail with access denied error even if |src| isn't there.
   432  			// And this is to normalize returned error in such case.
   433  			// https://crbug.com/1098265
   434  			err = errors.Annotate(serr, "%s doesn't exist and os.Link failed: %v\nlogs:\n%s", src, err, d.log.String()).Err()
   435  		}
   436  		debugInfo := fmt.Sprintf("Stats:\n*  src: %s\n*  dest: %s\n*  destDir: %s\nUID=%d GID=%d", statsStr(src), statsStr(dest), statsStr(filepath.Dir(dest)), os.Getuid(), os.Getgid())
   437  		return errors.Annotate(err, "failed to call makeHardLinkOrClone(%s, %s)\n%s", src, dest, debugInfo).Err()
   438  	}
   439  
   440  	if err := os.Chmod(dest, perm); err != nil {
   441  		return errors.Annotate(err, "failed to call os.Chmod(%s, %#o)", dest, perm).Err()
   442  	}
   443  
   444  	fi, err := os.Stat(dest)
   445  	if err != nil {
   446  		return errors.Annotate(err, "failed to call os.Stat(%s)", dest).Err()
   447  	}
   448  	size := fi.Size()
   449  	d.statsMu.Lock()
   450  	defer d.statsMu.Unlock()
   451  	// If this succeeds directly, it means the file is already cached on the
   452  	// disk, so we put it into LRU.
   453  	d.used = append(d.used, size)
   454  
   455  	return nil
   456  }
   457  
   458  func (d *Cache) itemPath(digest HexDigest) string {
   459  	return filepath.Join(d.path, string(digest))
   460  }
   461  
   462  func (d *Cache) statePath() string {
   463  	return filepath.Join(d.path, "state.json")
   464  }
   465  
   466  func (d *Cache) respectPolicies(ctx context.Context) error {
   467  	ctx, task := trace.NewTask(ctx, "respectPolicies")
   468  	defer task.End()
   469  
   470  	minFreeSpaceWanted := uint64(d.policies.MinFreeSpace)
   471  	for {
   472  		freeSpace, err := filesystem.GetFreeSpace(d.path)
   473  		if err != nil {
   474  			return errors.Annotate(err, "couldn't estimate the free space at %s", d.path).Err()
   475  		}
   476  		if (d.policies.MaxItems == 0 || d.lru.length() <= d.policies.MaxItems) && d.policies.fitsCacheSize(d.lru.sum) && freeSpace >= minFreeSpaceWanted {
   477  			break
   478  		}
   479  		if d.lru.length() == 0 {
   480  			d.freeSpaceWarningOnce.Do(func() {
   481  				// TODO(crbug.com/chrome-operations/49): make this error again.
   482  				logging.Warningf(ctx, "no more space to free in %s: current free space=%d policies.MinFreeSpace=%d", d.path, freeSpace, minFreeSpaceWanted)
   483  			})
   484  
   485  			break
   486  		}
   487  		k, _ := d.lru.popOldest()
   488  		_ = os.Remove(d.itemPath(k))
   489  	}
   490  	return nil
   491  }
   492  
   493  func statsStr(path string) string {
   494  	fi, err := os.Stat(path)
   495  	return fmt.Sprintf("path=%s FileInfo=%+v err=%v", path, fi, err)
   496  }