| // Copyright 2026 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package hashfs |
| |
| import ( |
| "context" |
| "fmt" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "time" |
| |
| pb "go.chromium.org/build/siso/hashfs/proto" |
| ) |
| |
| // BuildLabelGCSweepStrategy configures how we determine which outputs are obsolete. |
| type BuildLabelGCSweepStrategy struct { |
| RetainLastX int |
| OlderThan time.Duration |
| KeepLabels []string |
| EvictLabels []string |
| } |
| |
| // BuildLabelGCOptions configures the garbage collection run. |
| type BuildLabelGCOptions struct { |
| Strategy BuildLabelGCSweepStrategy |
| DryRun bool |
| } |
| |
| // BuildLabelGCResult represents the total summary of the garbage collection sweep. |
| type BuildLabelGCResult struct { |
| TotalFilesDeleted int |
| TotalBytesReclaimed int64 |
| EvictedBuildLabels []string |
| FailedDeletions []string |
| EvictedFiles []string |
| } |
| |
| // GarbageCollectBuildLabels deletes files exclusively generated by evicted build labels based on BuildLabelGCOptions. |
| func (hfs *HashFS) GarbageCollectBuildLabels(ctx context.Context, opts BuildLabelGCOptions) (BuildLabelGCResult, error) { |
| activeRegexes, isKeepRegex, err := validateGCStrategy(opts) |
| if err != nil { |
| return BuildLabelGCResult{}, err |
| } |
| |
| evictMask, _, evictedBuildLabels, toDelete := hfs.computeEvictionMasks(opts, activeRegexes, isKeepRegex) |
| successfullyDeleted, failedDeletions, totalDeleted, totalBytes, sweepErr := hfs.performActualDeletion(ctx, opts, toDelete) |
| hfs.updateLedger(opts, evictMask, successfullyDeleted) |
| |
| evictedFiles := successfullyDeleted |
| if opts.DryRun { |
| evictedFiles = toDelete |
| } |
| |
| res := BuildLabelGCResult{ |
| TotalFilesDeleted: totalDeleted, |
| TotalBytesReclaimed: totalBytes, |
| EvictedBuildLabels: evictedBuildLabels, |
| FailedDeletions: failedDeletions, |
| EvictedFiles: evictedFiles, |
| } |
| |
| return res, sweepErr |
| } |
| |
| func validateGCStrategy(opts BuildLabelGCOptions) ([]*regexp.Regexp, bool, error) { |
| var activeStrategies int |
| if opts.Strategy.RetainLastX > 0 { |
| activeStrategies++ |
| } |
| if opts.Strategy.OlderThan > 0 { |
| activeStrategies++ |
| } |
| if len(opts.Strategy.KeepLabels) > 0 { |
| activeStrategies++ |
| } |
| if len(opts.Strategy.EvictLabels) > 0 { |
| activeStrategies++ |
| } |
| |
| if activeStrategies == 0 { |
| return nil, false, fmt.Errorf("no garbage collection strategy specified") |
| } |
| if activeStrategies > 1 { |
| return nil, false, fmt.Errorf("multiple GC strategies set; strategies are mutually exclusive") |
| } |
| |
| var activeRegexes []*regexp.Regexp |
| isKeepRegex := len(opts.Strategy.KeepLabels) > 0 |
| patterns := opts.Strategy.KeepLabels |
| if !isKeepRegex { |
| patterns = opts.Strategy.EvictLabels |
| } |
| for _, pattern := range patterns { |
| r, err := regexp.Compile(pattern) |
| if err != nil { |
| return nil, false, fmt.Errorf("invalid regex %q: %w", pattern, err) |
| } |
| activeRegexes = append(activeRegexes, r) |
| } |
| |
| return activeRegexes, isKeepRegex, nil |
| } |
| |
| func (hfs *HashFS) computeEvictionMasks(opts BuildLabelGCOptions, activeRegexes []*regexp.Regexp, isKeepRegex bool) (evictMask, keepMask uint64, evictedBuildLabels, toDelete []string) { |
| hfs.ledgerMu.Lock() |
| defer hfs.ledgerMu.Unlock() |
| |
| type MetaWithID struct { |
| ID uint32 |
| Meta *pb.BuildLabelMetadata |
| } |
| var all []MetaWithID |
| for id, metadata := range hfs.buildLabelDictionary { |
| if metadata == nil { |
| continue |
| } |
| if id >= 64 { |
| continue |
| } |
| all = append(all, MetaWithID{id, metadata}) |
| } |
| |
| if opts.Strategy.RetainLastX > 0 { |
| sort.Slice(all, func(i, j int) bool { |
| if all[i].Meta.LastBuildTimestamp == all[j].Meta.LastBuildTimestamp { |
| return all[i].ID < all[j].ID |
| } |
| return all[i].Meta.LastBuildTimestamp > all[j].Meta.LastBuildTimestamp |
| }) |
| for i, entry := range all { |
| if i < opts.Strategy.RetainLastX { |
| keepMask |= (uint64(1) << entry.ID) |
| } else { |
| evictMask |= (uint64(1) << entry.ID) |
| } |
| } |
| } else if opts.Strategy.OlderThan > 0 { |
| cutoff := time.Now().UnixNano() - opts.Strategy.OlderThan.Nanoseconds() |
| for _, entry := range all { |
| if entry.Meta.LastBuildTimestamp < cutoff { |
| evictMask |= (uint64(1) << entry.ID) |
| } else { |
| keepMask |= (uint64(1) << entry.ID) |
| } |
| } |
| } else if len(activeRegexes) > 0 { |
| for _, entry := range all { |
| matched := false |
| for _, r := range activeRegexes { |
| if r.MatchString(entry.Meta.BuildLabel) { |
| matched = true |
| break |
| } |
| } |
| if matched == isKeepRegex { |
| keepMask |= (uint64(1) << entry.ID) |
| } else { |
| evictMask |= (uint64(1) << entry.ID) |
| } |
| } |
| } |
| |
| for _, entry := range all { |
| if (uint64(1)<<entry.ID)&evictMask != 0 { |
| evictedBuildLabels = append(evictedBuildLabels, entry.Meta.BuildLabel) |
| } |
| } |
| sort.Strings(evictedBuildLabels) |
| |
| for absPath, fileMask := range hfs.fileBuildLabels { |
| if fileMask == 0 { |
| continue |
| } |
| if (fileMask&evictMask) != 0 && (fileMask&keepMask) == 0 { |
| toDelete = append(toDelete, absPath) |
| } |
| } |
| |
| sort.Strings(toDelete) |
| return evictMask, keepMask, evictedBuildLabels, toDelete |
| } |
| |
| func (hfs *HashFS) performActualDeletion(ctx context.Context, opts BuildLabelGCOptions, toDelete []string) (successfullyDeleted, failedDeletions []string, totalDeleted int, totalBytes int64, sweepErr error) { |
| for _, absPath := range toDelete { |
| if ctx.Err() != nil { |
| sweepErr = ctx.Err() |
| break |
| } |
| localPath := filepath.FromSlash(absPath) |
| info, err := os.Lstat(localPath) |
| if err != nil { |
| if os.IsNotExist(err) { |
| if !opts.DryRun { |
| successfullyDeleted = append(successfullyDeleted, absPath) |
| } |
| } else { |
| failedDeletions = append(failedDeletions, absPath) |
| } |
| continue |
| } |
| size := info.Size() |
| |
| if !opts.DryRun { |
| err = os.Remove(localPath) |
| if err != nil { |
| if os.IsNotExist(err) { |
| successfullyDeleted = append(successfullyDeleted, absPath) |
| continue |
| } |
| failedDeletions = append(failedDeletions, absPath) |
| continue |
| } |
| successfullyDeleted = append(successfullyDeleted, absPath) |
| } |
| |
| totalDeleted++ |
| totalBytes += size |
| } |
| return successfullyDeleted, failedDeletions, totalDeleted, totalBytes, sweepErr |
| } |
| |
| func (hfs *HashFS) updateLedger(opts BuildLabelGCOptions, evictMask uint64, successfullyDeleted []string) { |
| if opts.DryRun { |
| return |
| } |
| |
| hfs.ledgerMu.Lock() |
| defer hfs.ledgerMu.Unlock() |
| |
| for _, absPath := range successfullyDeleted { |
| delete(hfs.fileBuildLabels, absPath) |
| } |
| |
| var survivingMask uint64 |
| for _, fileMask := range hfs.fileBuildLabels { |
| survivingMask |= fileMask |
| } |
| |
| actuallyEvictedMask := evictMask &^ survivingMask |
| if actuallyEvictedMask == 0 { |
| return |
| } |
| |
| for id := range hfs.buildLabelDictionary { |
| if id >= 64 { |
| continue |
| } |
| if (uint64(1)<<id)&actuallyEvictedMask != 0 { |
| delete(hfs.buildLabelDictionary, id) |
| } |
| } |
| } |