Skip to content

Commit 58648d2

Browse files
committed
gc
1 parent c38a3d5 commit 58648d2

8 files changed

Lines changed: 146 additions & 88 deletions

File tree

gc/gc.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,23 @@ func (m Module[S]) resolveTargets(snap any, others map[string]any) []string {
4040
func (m Module[S]) collect(ctx context.Context, ids []string) error {
4141
return m.Collect(ctx, ids)
4242
}
43+
44+
// UsedBlobIDs is optionally implemented by GC snapshots that reference image blobs.
45+
// Image GC modules use CollectUsedBlobIDs to skip dangling blobs still needed by VMs.
46+
type UsedBlobIDs interface {
47+
UsedBlobIDs() map[string]struct{}
48+
}
49+
50+
// CollectUsedBlobIDs aggregates all blob hex IDs referenced by UsedBlobIDs
51+
// snapshots in others.
52+
func CollectUsedBlobIDs(others map[string]any) map[string]struct{} {
53+
used := make(map[string]struct{})
54+
for _, snap := range others {
55+
if u, ok := snap.(UsedBlobIDs); ok {
56+
for id := range u.UsedBlobIDs() {
57+
used[id] = struct{}{}
58+
}
59+
}
60+
}
61+
return used
62+
}

gc/orchestrator.go

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,41 +25,50 @@ func Register[S any](o *Orchestrator, m Module[S]) {
2525

2626
// Run executes one GC cycle:
2727
//
28-
// 1. For each module: TryLock → ReadDB → Unlock (skip if busy).
29-
// 2. Each module's Resolve analyses its typed snapshot, with other modules'
30-
// snapshots available as map[string]any for cross-module analysis.
31-
// 3. For each snapshotted module: TryLock → Collect → Unlock (skip if busy).
32-
// Collect is called even with empty targets so modules can run housekeeping
33-
// (e.g. stale temp cleanup).
28+
// 1. TryLock all modules; skip those whose lock is busy.
29+
// 2. ReadDB each locked module to build a snapshot.
30+
// 3. Resolve deletion targets per module. Each module's Resolve receives
31+
// all other snapshots (typed as any) for cross-module analysis — e.g.,
32+
// image GC checks UsedBlobIDs from the VM snapshot to protect active blobs.
33+
// 4. Collect targets for each snapshotted module.
34+
// 5. Unlock all (deferred).
3435
//
35-
// Step 3 re-acquires the lock rather than holding it from step 1 to keep
36-
// contention minimal. commitAndRecord validates blob existence under lock
37-
// before writing the index, so a deletion racing with a commit is caught
38-
// there and the pull retries.
36+
// All locks are held for the entire cycle so that the snapshot, resolve, and
37+
// collect phases see a consistent view. GC runs infrequently and executes
38+
// fast, so the extended lock hold is acceptable.
3939
func (o *Orchestrator) Run(ctx context.Context) error {
4040
logger := log.WithFunc("gc.Run")
4141

42-
// Phase 1: collect each module's snapshot under lock.
43-
snapshots := make(map[string]any, len(o.modules))
42+
// Acquire all locks up front; hold until GC finishes.
43+
var locked []runner
4444
for _, m := range o.modules {
4545
ok, err := m.getLocker().TryLock(ctx)
4646
if err != nil || !ok {
4747
logger.Warnf(ctx, "skip %s: lock busy", m.getName())
4848
continue
4949
}
50-
snap, readErr := m.readSnapshot(ctx)
51-
m.getLocker().Unlock(ctx) //nolint:errcheck
52-
if readErr != nil {
53-
logger.Warnf(ctx, "snapshot %s: %v", m.getName(), readErr)
50+
locked = append(locked, m)
51+
}
52+
defer func() {
53+
for _, m := range locked {
54+
m.getLocker().Unlock(ctx) //nolint:errcheck
55+
}
56+
}()
57+
58+
// Phase 1: snapshot all locked modules.
59+
snapshots := make(map[string]any, len(locked))
60+
for _, m := range locked {
61+
snap, err := m.readSnapshot(ctx)
62+
if err != nil {
63+
logger.Warnf(ctx, "snapshot %s: %v", m.getName(), err)
5464
continue
5565
}
5666
snapshots[m.getName()] = snap
5767
}
5868

59-
// Phase 2: resolve deletion targets per module (no locks held).
60-
// Each module sees its own snapshot typed, others as any.
69+
// Phase 2: resolve deletion targets (cross-module via snapshots).
6170
targets := make(map[string][]string)
62-
for _, m := range o.modules {
71+
for _, m := range locked {
6372
snap, ok := snapshots[m.getName()]
6473
if !ok {
6574
continue
@@ -69,23 +78,15 @@ func (o *Orchestrator) Run(ctx context.Context) error {
6978
}
7079
}
7180

72-
// Phase 3: collect under lock for every snapshotted module.
73-
// Collect is called even with empty targets so modules can do housekeeping.
81+
// Phase 3: collect.
7482
var errs []string
75-
for _, m := range o.modules {
83+
for _, m := range locked {
7684
if _, snapshotted := snapshots[m.getName()]; !snapshotted {
7785
continue
7886
}
79-
ids := targets[m.getName()] // nil if no targets — Collect handles this
80-
ok, err := m.getLocker().TryLock(ctx)
81-
if err != nil || !ok {
82-
logger.Warnf(ctx, "skip collect %s: lock busy", m.getName())
83-
continue
84-
}
85-
collectErr := m.collect(ctx, ids)
86-
m.getLocker().Unlock(ctx) //nolint:errcheck
87-
if collectErr != nil {
88-
errs = append(errs, fmt.Sprintf("%s: %v", m.getName(), collectErr))
87+
ids := targets[m.getName()]
88+
if err := m.collect(ctx, ids); err != nil {
89+
errs = append(errs, fmt.Sprintf("%s: %v", m.getName(), err))
8990
}
9091
}
9192
if len(errs) > 0 {

hypervisor/cloudhypervisor/cloudhypervisor.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,16 @@ func (ch *CloudHypervisor) Delete(ctx context.Context, ids []string, force bool)
8484
return fmt.Errorf("stop before delete: %w", err)
8585
}
8686
}
87-
return ch.store.Update(ctx, func(idx *hypervisor.VMIndex) error {
87+
if err := ch.store.Update(ctx, func(idx *hypervisor.VMIndex) error {
8888
if _, ok := idx.VMs[id]; !ok {
8989
return hypervisor.ErrNotFound
9090
}
9191
delete(idx.VMs, id)
9292
return nil
93-
})
93+
}); err != nil {
94+
return err
95+
}
96+
ch.cleanupRuntimeFiles(id)
97+
return nil
9498
})
9599
}

hypervisor/cloudhypervisor/create.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os/exec"
7+
"path/filepath"
78
"slices"
89
"strings"
910
"time"
@@ -31,6 +32,9 @@ func (ch *CloudHypervisor) Create(ctx context.Context, vmCfg *types.VMConfig, st
3132
boot = *bootCfg
3233
}
3334

35+
// Extract blob IDs before prepare transforms the StorageConfigs.
36+
blobIDs := extractBlobIDs(storageConfigs, bootCfg)
37+
3438
var (
3539
sc []*types.StorageConfig
3640
err error
@@ -56,6 +60,7 @@ func (ch *CloudHypervisor) Create(ctx context.Context, vmCfg *types.VMConfig, st
5660
VMInfo: info,
5761
StorageConfigs: sc,
5862
BootConfig: &boot,
63+
ImageBlobIDs: blobIDs,
5964
}
6065

6166
if err := ch.store.Update(ctx, func(idx *hypervisor.VMIndex) error {
@@ -150,3 +155,33 @@ func ReverseLayerSerials(sc []*types.StorageConfig) []string {
150155
slices.Reverse(serials)
151156
return serials
152157
}
158+
159+
// extractBlobIDs extracts digest hexes from the original image StorageConfigs
160+
// and BootConfig paths. Must be called before prepare transforms them.
161+
func extractBlobIDs(sc []*types.StorageConfig, boot *types.BootConfig) map[string]struct{} {
162+
ids := make(map[string]struct{})
163+
if boot != nil && boot.KernelPath != "" {
164+
// OCI: erofs layer blobs + boot dir hexes.
165+
for _, s := range sc {
166+
if s.RO {
167+
ids[blobHexFromPath(s.Path)] = struct{}{}
168+
}
169+
}
170+
// boot/{hex}/vmlinuz → hex
171+
ids[filepath.Base(filepath.Dir(boot.KernelPath))] = struct{}{}
172+
if boot.InitrdPath != "" {
173+
ids[filepath.Base(filepath.Dir(boot.InitrdPath))] = struct{}{}
174+
}
175+
} else if len(sc) > 0 {
176+
// Cloudimg: base qcow2 blob hex (before overlay replaces it).
177+
ids[blobHexFromPath(sc[0].Path)] = struct{}{}
178+
}
179+
return ids
180+
}
181+
182+
// blobHexFromPath extracts the digest hex from a blob file path.
183+
// e.g., "/var/lib/cocoon/oci/blobs/abc123.erofs" → "abc123"
184+
func blobHexFromPath(path string) string {
185+
base := filepath.Base(path)
186+
return strings.TrimSuffix(base, filepath.Ext(base))
187+
}

hypervisor/cloudhypervisor/gc.go

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,78 +2,53 @@ package cloudhypervisor
22

33
import (
44
"context"
5-
"errors"
6-
"os"
7-
"path/filepath"
85

96
"github.com/projecteru2/cocoon/gc"
107
"github.com/projecteru2/cocoon/hypervisor"
11-
"github.com/projecteru2/cocoon/utils"
128
)
139

1410
// compile-time interface check.
1511
var _ hypervisor.Hypervisor = (*CloudHypervisor)(nil)
1612

1713
// chSnapshot is the typed GC snapshot for the Cloud Hypervisor backend.
14+
// Its sole purpose is to expose the union of all VMs' ImageBlobIDs so that
15+
// image GC modules can skip blobs still needed by active VMs.
1816
type chSnapshot struct {
19-
vmIDs map[string]struct{} // VM IDs present in the DB index
20-
runDirs []string // VM IDs that have a runtime dir on disk
21-
logDirs []string // VM IDs that have a log dir on disk
17+
blobIDs map[string]struct{} // union of all VMs' ImageBlobIDs
2218
}
2319

20+
// UsedBlobIDs implements gc.UsedBlobIDs.
21+
func (s chSnapshot) UsedBlobIDs() map[string]struct{} { return s.blobIDs }
22+
2423
// GCModule returns a typed gc.Module[chSnapshot] for the Cloud Hypervisor backend.
2524
//
26-
// ReadDB (under lock): reads the VM index and scans the runtime/log base dirs
27-
// on disk, returning a snapshot of what exists vs what is recorded.
28-
//
29-
// Resolve: returns VM IDs whose runtime or log dirs are orphaned (present on
30-
// disk but no longer in the DB).
31-
//
32-
// Collect (under lock): removes the orphaned dirs for each VM ID.
25+
// ReadDB (under lock): collects all image blob IDs referenced by VMs.
26+
// Resolve/Collect are no-ops — VM residual cleanup is done by Delete directly.
27+
// The module exists purely as a data provider for cross-module GC.
3328
func (ch *CloudHypervisor) GCModule() gc.Module[chSnapshot] {
3429
return gc.Module[chSnapshot]{
3530
Name: typ,
3631
Locker: ch.locker,
3732
ReadDB: func(ctx context.Context) (chSnapshot, error) {
3833
var snap chSnapshot
3934
if err := ch.store.Read(func(idx *hypervisor.VMIndex) error {
40-
snap.vmIDs = make(map[string]struct{}, len(idx.VMs))
41-
for id := range idx.VMs {
42-
snap.vmIDs[id] = struct{}{}
35+
snap.blobIDs = make(map[string]struct{})
36+
for _, rec := range idx.VMs {
37+
if rec == nil {
38+
continue
39+
}
40+
for hex := range rec.ImageBlobIDs {
41+
snap.blobIDs[hex] = struct{}{}
42+
}
4343
}
4444
return nil
4545
}); err != nil {
4646
return snap, err
4747
}
48-
snap.runDirs = utils.ScanSubdirs(filepath.Join(ch.conf.RunDir, "cloudhypervisor"))
49-
snap.logDirs = utils.ScanSubdirs(filepath.Join(ch.conf.LogDir, "cloudhypervisor"))
5048
return snap, nil
5149
},
52-
Resolve: func(snap chSnapshot, _ map[string]any) []string {
53-
unreferenced := utils.FilterUnreferenced(snap.runDirs, snap.vmIDs)
54-
seen := make(map[string]struct{}, len(unreferenced))
55-
for _, id := range unreferenced {
56-
seen[id] = struct{}{}
57-
}
58-
for _, id := range utils.FilterUnreferenced(snap.logDirs, snap.vmIDs) {
59-
if _, already := seen[id]; !already {
60-
unreferenced = append(unreferenced, id)
61-
}
62-
}
63-
return unreferenced
64-
},
65-
Collect: func(ctx context.Context, ids []string) error {
66-
var errs []error
67-
for _, vmID := range ids {
68-
if err := os.RemoveAll(ch.conf.CHVMRunDir(vmID)); err != nil && !os.IsNotExist(err) {
69-
errs = append(errs, err)
70-
}
71-
if err := os.RemoveAll(ch.conf.CHVMLogDir(vmID)); err != nil && !os.IsNotExist(err) {
72-
errs = append(errs, err)
73-
}
74-
}
75-
return errors.Join(errs...)
76-
},
50+
Resolve: func(_ chSnapshot, _ map[string]any) []string { return nil },
51+
Collect: func(_ context.Context, _ []string) error { return nil },
7752
}
7853
}
7954

hypervisor/db.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ type VMRecord struct {
2121
// BootConfig holds the kernel and initrd paths for direct-boot VMs.
2222
// Nil for UEFI-boot VMs (cloud images).
2323
BootConfig *types.BootConfig `json:"boot_config,omitempty"`
24+
25+
// ImageBlobIDs is the set of digest hex strings of image blobs this VM
26+
// depends on. Populated at create time from the original StorageConfigs
27+
// (before COW prepare). Image GC skips dangling blobs that appear here.
28+
ImageBlobIDs map[string]struct{} `json:"image_blob_ids,omitempty"`
2429
}
2530

2631
// VMIndex is the top-level DB structure shared by all hypervisor backends.

images/cloudimg/gc.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,17 @@ func (c *CloudImg) GCModule() gc.Module[cloudimgSnapshot] {
3131
snap.blobs = images.ScanBlobHexes(c.conf.CloudimgBlobsDir(), ".qcow2")
3232
return snap, nil
3333
},
34-
Resolve: func(snap cloudimgSnapshot, _ map[string]any) []string {
35-
return images.FilterUnreferenced(snap.blobs, snap.refs)
34+
Resolve: func(snap cloudimgSnapshot, others map[string]any) []string {
35+
used := gc.CollectUsedBlobIDs(others)
36+
37+
var result []string
38+
for _, hex := range images.FilterUnreferenced(snap.blobs, snap.refs) {
39+
if _, inUse := used[hex]; inUse {
40+
continue
41+
}
42+
result = append(result, hex)
43+
}
44+
return result
3645
},
3746
Collect: func(ctx context.Context, ids []string) error {
3847
var errs []error

images/oci/gc.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,28 @@ func (o *OCI) GCModule() gc.Module[ociSnapshot] {
3939
}
4040
return snap, nil
4141
},
42-
Resolve: func(snap ociSnapshot, _ map[string]any) []string {
43-
// Collect unreferenced blobs, then any boot dirs not already included.
44-
unreferenced := images.FilterUnreferenced(snap.blobs, snap.refs)
45-
seen := make(map[string]struct{}, len(unreferenced))
46-
for _, h := range unreferenced {
47-
seen[h] = struct{}{}
42+
Resolve: func(snap ociSnapshot, others map[string]any) []string {
43+
used := gc.CollectUsedBlobIDs(others)
44+
45+
dangling := images.FilterUnreferenced(snap.blobs, snap.refs)
46+
seen := make(map[string]struct{}, len(dangling))
47+
var result []string
48+
for _, hex := range dangling {
49+
if _, inUse := used[hex]; inUse {
50+
continue
51+
}
52+
result = append(result, hex)
53+
seen[hex] = struct{}{}
4854
}
4955
for _, hex := range images.FilterUnreferenced(snap.bootDirs, snap.refs) {
56+
if _, inUse := used[hex]; inUse {
57+
continue
58+
}
5059
if _, already := seen[hex]; !already {
51-
unreferenced = append(unreferenced, hex)
60+
result = append(result, hex)
5261
}
5362
}
54-
return unreferenced
63+
return result
5564
},
5665
Collect: func(ctx context.Context, ids []string) error {
5766
var errs []error

0 commit comments

Comments
 (0)