blob: 4c22b5c18a2b999af9eccf1f4a68c69cdb6d8741 [file]
// 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)
}
}
}