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 }