Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/fluxcd/pkg/cache v0.9.0
github.com/fluxcd/pkg/runtime v0.60.0
github.com/fluxcd/pkg/version v0.7.0
github.com/go-logr/logr v1.4.2
github.com/google/go-containerregistry v0.20.5
github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20250225234217-098045d5e61f
github.com/onsi/ginkgo v1.16.5
Expand Down Expand Up @@ -86,7 +87,6 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
Expand Down
94 changes: 94 additions & 0 deletions internal/database/badger_gc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
Copyright 2025 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package database

import (
"context"
"errors"
"time"

"github.com/dgraph-io/badger/v3"
"github.com/go-logr/logr"
ctrl "sigs.k8s.io/controller-runtime"
)

// BadgerGarbageCollector implements controller runtime's Runnable
type BadgerGarbageCollector struct {
// DiscardRatio must be a float between 0.0 and 1.0, inclusive
// See badger.DB.RunValueLogGC for more info
DiscardRatio float64
Interval time.Duration

name string
db *badger.DB
log logr.Logger
}

// NewBadgerGarbageCollector creates and returns a new BadgerGarbageCollector
func NewBadgerGarbageCollector(name string, db *badger.DB, interval time.Duration, discardRatio float64) *BadgerGarbageCollector {
return &BadgerGarbageCollector{
DiscardRatio: discardRatio,
Interval: interval,

name: name,
db: db,
}
}

// Start repeatedly runs the BadgerDB garbage collector with a delay inbetween
// runs.
//
// Start blocks until the context is cancelled. The database is expected to
// already be open and not be closed while this context is active.
//
// ctx should be a logr.Logger context.
func (gc *BadgerGarbageCollector) Start(ctx context.Context) error {
gc.log = ctrl.LoggerFrom(ctx).WithName(gc.name)

gc.log.Info("Starting Badger GC")
timer := time.NewTimer(gc.Interval)
for {
select {
case <-timer.C:
gc.discardValueLogFiles()
timer.Reset(gc.Interval)
case <-ctx.Done():
timer.Stop()
gc.log.Info("Stopped Badger GC")
return nil
}
}
}

// upper bound for loop
const maxDiscards = 1000
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is a reasonable value of discards, but it felt like the controller could sample, delete, and rewrite 1000 value log files relatively quickly in its own goroutine without impacting the controller-runtime too much.


func (gc *BadgerGarbageCollector) discardValueLogFiles() {
gc.log.V(1).Info("Running Badger GC")
for c := 0; c < maxDiscards; c++ {
err := gc.db.RunValueLogGC(gc.DiscardRatio)
if errors.Is(err, badger.ErrNoRewrite) {
// there is no more garbage to discard
gc.log.V(1).Info("Ran Badger GC", "discarded_vlogs", c)
return
}
if err != nil {
gc.log.Error(err, "Badger GC Error", "discarded_vlogs", c)
return
}
}
gc.log.Error(nil, "Warning: Badger GC ran for maximum discards", "discarded_vlogs", maxDiscards)
}
77 changes: 77 additions & 0 deletions internal/database/badger_gc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
Copyright 2020 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package database

import (
"context"
"os"
"testing"
"time"

"github.com/dgraph-io/badger/v3"
"github.com/go-logr/logr"
"github.com/go-logr/logr/testr"
)

func TestBadgerGarbageCollectorDoesStop(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example Successful run (2.3 seconds)

go test -run '^TestBadgerGarbageCollectorDoesStop$' github.com/fluxcd/image-reflector-controller/internal/database -v -count 1

=== RUN   TestBadgerGarbageCollectorDoesStop
badger 2025/05/07 00:04:06 INFO: All 0 tables opened in 0s
badger 2025/05/07 00:04:06 INFO: Discard stats nextEmptySlot: 0
badger 2025/05/07 00:04:06 INFO: Set nextTxnTs to 0
    badger_gc.go:61: test-badger-gc: "level"=0 "msg"="Starting Badger GC"
    badger_gc.go:84: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc_test.go:47: wrote tags successfully
    badger_gc.go:84: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc.go:84: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc_test.go:52: waiting for GC stop
    badger_gc.go:70: test-badger-gc: "level"=0 "msg"="Stopped Badger GC"
    badger_gc_test.go:57: GC Stopped
badger 2025/05/07 00:04:08 INFO: Lifetime L0 stalled for: 0s
badger 2025/05/07 00:04:08 INFO: 
Level 0 [ ]: NumTables: 01. Size: 216 B of 0 B. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 64 MiB
Level 1 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 2 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 3 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 4 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 5 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 6 [B]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level Done
--- PASS: TestBadgerGarbageCollectorDoesStop (2.06s)
PASS
ok  	github.com/fluxcd/image-reflector-controller/internal/database	2.355s

Synthetic Failure (sleep for a minute when stopped) (7.3 seconds)

go test -run '^TestBadgerGarbageCollectorDoesStop$' github.com/fluxcd/image-reflector-controller/internal/database -v -count 1

=== RUN   TestBadgerGarbageCollectorDoesStop
badger 2025/05/06 23:46:31 INFO: All 0 tables opened in 0s
badger 2025/05/06 23:46:31 INFO: Discard stats nextEmptySlot: 0
badger 2025/05/06 23:46:31 INFO: Set nextTxnTs to 0
    badger_gc.go:61: test-badger-gc: "level"=0 "msg"="Starting Badger GC"
    badger_gc.go:85: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc_test.go:47: wrote tags successfully
    badger_gc.go:85: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc.go:85: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc.go:85: test-badger-gc: "level"=0 "msg"="Ran Badger GC" "discarded_vlogs"=0
    badger_gc_test.go:52: waiting for GC stop
    badger_gc_test.go:55: GC did not stop
badger 2025/05/06 23:46:38 INFO: Lifetime L0 stalled for: 0s
badger 2025/05/06 23:46:38 INFO: 
Level 0 [ ]: NumTables: 01. Size: 216 B of 0 B. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 64 MiB
Level 1 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 2 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 3 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 4 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 5 [ ]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level 6 [B]: NumTables: 00. Size: 0 B of 10 MiB. Score: 0.00->0.00 StaleData: 0 B Target FileSize: 2.0 MiB
Level Done
--- FAIL: TestBadgerGarbageCollectorDoesStop (7.04s)
FAIL
FAIL	github.com/fluxcd/image-reflector-controller/internal/database	7.332s
FAIL

badger, db := createBadgerDatabaseForGC(t)
ctx, cancel := context.WithCancel(
logr.NewContext(context.Background(),
testr.NewWithOptions(t, testr.Options{Verbosity: 1, LogTimestamp: true})))

stop := make(chan struct{})
go func() {
gc := NewBadgerGarbageCollector("test-badger-gc", badger, 500*time.Millisecond, 0.01)
gc.Start(ctx)
stop <- struct{}{}
}()

time.Sleep(time.Second)

tags := []string{"latest", "v0.0.1", "v0.0.2"}
fatalIfError(t, db.SetTags(testRepo, tags))
_, err := db.Tags(testRepo)
fatalIfError(t, err)
t.Log("wrote tags successfully")

time.Sleep(time.Second)

cancel()
t.Log("waiting for GC stop")
select {
case <-time.NewTimer(5 * time.Second).C:
t.Fatalf("GC did not stop")
case <-stop:
t.Log("GC Stopped")
}
}

func createBadgerDatabaseForGC(t *testing.T) (*badger.DB, *BadgerDatabase) {
t.Helper()
dir, err := os.MkdirTemp(os.TempDir(), t.Name())
if err != nil {
t.Fatal(err)
}
db, err := badger.Open(badger.DefaultOptions(dir))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
os.RemoveAll(dir)
})
return db, NewBadgerDatabase(db)
}
19 changes: 18 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"os"
"time"

"github.com/dgraph-io/badger/v3"
flag "github.com/spf13/pflag"
Expand Down Expand Up @@ -61,7 +62,10 @@ import (
"github.com/fluxcd/image-reflector-controller/internal/registry"
)

const controllerName = "image-reflector-controller"
const (
controllerName = "image-reflector-controller"
discardRatio = 0.7
)

var (
scheme = runtime.NewScheme()
Expand Down Expand Up @@ -90,6 +94,7 @@ func main() {
watchOptions helper.WatchOptions
storagePath string
storageValueLogFileSize int64
gcInterval uint16 // max value is 65535 minutes (~ 45 days) which is well under the maximum time.Duration
concurrent int
awsAutoLogin bool
gcpAutoLogin bool
Expand All @@ -105,6 +110,7 @@ func main() {
flag.StringVar(&healthAddr, "health-addr", ":9440", "The address the health endpoint binds to.")
flag.StringVar(&storagePath, "storage-path", "/data", "Where to store the persistent database of image metadata")
flag.Int64Var(&storageValueLogFileSize, "storage-value-log-file-size", 1<<28, "Set the database's memory mapped value log file size in bytes. Effective memory usage is about two times this size.")
flag.Uint16Var(&gcInterval, "gc-interval", 10, "The number of minutes to wait between garbage collections. 0 disables the garbage collector.")
flag.IntVar(&concurrent, "concurrent", 4, "The number of concurrent resource reconciles.")

// NOTE: Deprecated flags.
Expand Down Expand Up @@ -152,7 +158,14 @@ func main() {
os.Exit(1)
}
defer badgerDB.Close()

db := database.NewBadgerDatabase(badgerDB)
var badgerGC *database.BadgerGarbageCollector
if gcInterval > 0 {
badgerGC = database.NewBadgerGarbageCollector("badger-gc", badgerDB, time.Duration(gcInterval)*time.Minute, discardRatio)
} else {
setupLog.V(1).Info("Badger garbage collector is disabled")
}

watchNamespace := ""
if !watchOptions.AllNamespaces {
Expand Down Expand Up @@ -225,6 +238,10 @@ func main() {
os.Exit(1)
}

if badgerGC != nil {
mgr.Add(badgerGC)
}

probes.SetupChecks(mgr, setupLog)

var eventRecorder *events.Recorder
Expand Down
Loading