Files
hauler/pkg/content/stats_test.go
T
2026-08-05 16:34:41 -04:00

149 lines
4.0 KiB
Go

package content
import (
"context"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/opencontainers/go-digest"
)
// writeTestBlob writes content through WriteBlob and returns its digest.
func writeTestBlob(t *testing.T, o *OCI, body string) digest.Digest {
t.Helper()
dg := digest.FromString(body)
err := o.WriteBlob(context.Background(), dg, int64(len(body)), func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(body)), nil
})
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
return dg
}
func TestIOStatsCountsWrittenAndCached(t *testing.T) {
o, err := NewOCI(t.TempDir())
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
writeTestBlob(t, o, "hello world")
st := o.Stats().Snapshot()
if st.BlobsWritten != 1 {
t.Fatalf("BlobsWritten = %d, want 1", st.BlobsWritten)
}
if st.BlobsCached != 0 {
t.Fatalf("BlobsCached = %d, want 0", st.BlobsCached)
}
if st.BlobBytesWritten != int64(len("hello world")) {
t.Fatalf("BlobBytesWritten = %d, want %d", st.BlobBytesWritten, len("hello world"))
}
// Same digest again: must hit the os.Stat fast path, not rewrite.
writeTestBlob(t, o, "hello world")
st = o.Stats().Snapshot()
if st.BlobsWritten != 1 {
t.Fatalf("BlobsWritten = %d after rewrite, want 1", st.BlobsWritten)
}
if st.BlobsCached != 1 {
t.Fatalf("BlobsCached = %d, want 1", st.BlobsCached)
}
if st.BlobBytesWritten != int64(len("hello world")) {
t.Fatalf("BlobBytesWritten = %d after cache hit, want unchanged", st.BlobBytesWritten)
}
}
func TestIOStatsPeakInFlightNeverExceedsCeiling(t *testing.T) {
const ceiling = 3
o, err := NewOCI(t.TempDir(), WithBlobConcurrency(ceiling))
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
if got := o.BlobConcurrency(); got != ceiling {
t.Fatalf("BlobConcurrency() = %d, want %d", got, ceiling)
}
var wg sync.WaitGroup
for i := 0; i < 40; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := strings.Repeat("x", 1024) + string(rune('a'+i%26)) + strings.Repeat("y", i)
dg := digest.FromString(body)
_ = o.WriteBlob(context.Background(), dg, int64(len(body)), func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(body)), nil
})
}(i)
}
wg.Wait()
st := o.Stats().Snapshot()
if st.BlobPeakInFlight > ceiling {
t.Fatalf("BlobPeakInFlight = %d, must never exceed the ceiling of %d", st.BlobPeakInFlight, ceiling)
}
if st.BlobPeakInFlight < 1 {
t.Fatalf("BlobPeakInFlight = %d, expected at least 1 concurrent write to be observed", st.BlobPeakInFlight)
}
}
func TestIOStatsCountsIndexWrites(t *testing.T) {
o, err := NewOCI(t.TempDir())
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
// o.index is only populated by LoadIndex; SaveIndex on a bare OCI (no
// prior LoadIndex) is a nil-pointer panic by design -- see newTestOCI's
// doc comment in oci_concurrency_test.go. Mirror that same pattern here.
if err := o.LoadIndex(); err != nil {
t.Fatalf("LoadIndex: %v", err)
}
if err := o.SaveIndex(); err != nil {
t.Fatalf("SaveIndex: %v", err)
}
st := o.Stats().Snapshot()
if st.IndexWrites != 1 {
t.Fatalf("IndexWrites = %d, want 1", st.IndexWrites)
}
if st.IndexBytesWritten <= 0 {
t.Fatalf("IndexBytesWritten = %d, want > 0", st.IndexBytesWritten)
}
}
func TestIOStatsRecordsLockWait(t *testing.T) {
o, err := NewOCI(t.TempDir())
if err != nil {
t.Fatalf("NewOCI: %v", err)
}
// See TestIOStatsCountsIndexWrites: o.index must be populated before
// SaveIndex can run, so load it before we grab o.mu below.
if err := o.LoadIndex(); err != nil {
t.Fatalf("LoadIndex: %v", err)
}
// Hold the mutex directly so the next lock() call must block on it.
o.mu.Lock()
done := make(chan struct{})
go func() {
defer close(done)
_ = o.SaveIndex()
}()
// Give the goroutine time to reach lock() and block there.
time.Sleep(50 * time.Millisecond)
o.mu.Unlock()
<-done
st := o.Stats().Snapshot()
if st.IndexLockWait < 10*time.Millisecond {
t.Fatalf("IndexLockWait = %v, want at least 10ms of recorded contention", st.IndexLockWait)
}
}