owpengram-server/internal/loadtest/media_download_load_test.go

245 lines
6.5 KiB
Go

package loadtest
import (
"context"
"fmt"
"io"
"os"
"sort"
"sync"
"sync/atomic"
"testing"
"time"
filesapp "telesrv/internal/app/files"
"telesrv/internal/domain"
"telesrv/internal/store/postgres"
)
// TestMediaDownloadRangeBaseline 用真实 PostgreSQL 元数据 + localfs blob backend
// 压测 upload.getFile 背后的 files.Service.GetFile range 读路径。默认跳过;
// 设置 TELESRV_MEDIA_DOWNLOAD_LOAD=1 和 TELESRV_TEST_POSTGRES_DSN 后运行。
func TestMediaDownloadRangeBaseline(t *testing.T) {
if os.Getenv("TELESRV_MEDIA_DOWNLOAD_LOAD") != "1" {
t.Skip("set TELESRV_MEDIA_DOWNLOAD_LOAD=1 to run media download baseline")
}
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run media download baseline")
}
ctx := context.Background()
if err := postgres.Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := postgres.Open(ctx, dsn, postgres.WithMaxConns(envInt("TELESRV_MEDIA_LOAD_POOL_CONNS", 16)))
if err != nil {
t.Fatalf("open postgres: %v", err)
}
t.Cleanup(pool.Close)
blobDir := os.Getenv("TELESRV_MEDIA_LOAD_BLOB_DIR")
if blobDir == "" {
blobDir = t.TempDir()
}
blobs, err := filesapp.NewLocalFS(blobDir)
if err != nil {
t.Fatalf("new localfs: %v", err)
}
mediaStore := postgres.NewMediaStore(pool)
svc := filesapp.NewService(mediaStore, blobs, 2, filesapp.WithVideoThumbnailer(nil))
runID := time.Now().UnixNano()
bodyKey := fmt.Sprintf("loadtest:doc:%d", runID)
thumbKey := fmt.Sprintf("loadtest:doc:%d:m", runID)
bodyBytes := int64(envInt("TELESRV_MEDIA_LOAD_BLOB_BYTES", 64<<20))
if bodyBytes < 1<<20 {
bodyBytes = 1 << 20
}
chunkSize := envInt("TELESRV_MEDIA_LOAD_CHUNK_BYTES", 512<<10)
if chunkSize < 1 {
chunkSize = 512 << 10
}
thumbBytes := envInt("TELESRV_MEDIA_LOAD_THUMB_BYTES", 96<<10)
if thumbBytes < 1 {
thumbBytes = 96 << 10
}
bodyObject, bodySize, bodySHA, err := blobs.PutReader(ctx, newPatternReader(bodyBytes))
if err != nil {
t.Fatalf("put body blob: %v", err)
}
thumbObject, thumbSize, thumbSHA, err := blobs.PutReader(ctx, newPatternReader(int64(thumbBytes)))
if err != nil {
t.Fatalf("put thumb blob: %v", err)
}
if err := mediaStore.PutFileBlob(ctx, domain.FileBlob{
LocationKey: bodyKey,
Backend: domain.MediaBackend(blobs.Name()),
ObjectKey: bodyObject,
Size: bodySize,
SHA256: bodySHA,
MimeType: "video/mp4",
}); err != nil {
t.Fatalf("put body file blob: %v", err)
}
if err := mediaStore.PutFileBlob(ctx, domain.FileBlob{
LocationKey: thumbKey,
Backend: domain.MediaBackend(blobs.Name()),
ObjectKey: thumbObject,
Size: thumbSize,
SHA256: thumbSHA,
MimeType: "image/jpeg",
}); err != nil {
t.Fatalf("put thumb file blob: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key IN ($1, $2)", bodyKey, thumbKey)
})
concurrency := envInt("TELESRV_MEDIA_LOAD_CONCURRENCY", 16)
if concurrency < 1 {
concurrency = 1
}
requests := envInt("TELESRV_MEDIA_LOAD_REQUESTS", 2000)
if requests < 1 {
requests = 1
}
thumbRequests := envInt("TELESRV_MEDIA_LOAD_THUMB_REQUESTS", 500)
if thumbRequests < 0 {
thumbRequests = 0
}
bodyResult := runGetFileLoad(ctx, t, svc, bodyKey, bodyBytes, chunkSize, concurrency, requests)
t.Logf("media body range load: requests=%d concurrency=%d chunk=%d blob=%d wall=%s throughput=%.1f req/s %.1f MiB/s p50=%s p95=%s p99=%s errors=%d",
requests,
concurrency,
chunkSize,
bodyBytes,
bodyResult.wall,
float64(requests)/bodyResult.wall.Seconds(),
float64(bodyResult.bytes)/(1024*1024)/bodyResult.wall.Seconds(),
bodyResult.p50,
bodyResult.p95,
bodyResult.p99,
bodyResult.errors,
)
if bodyResult.errors > 0 {
t.Fatalf("body range load errors=%d", bodyResult.errors)
}
if thumbRequests > 0 {
thumbResult := runGetFileLoad(ctx, t, svc, thumbKey, thumbSize, int(thumbSize), max(1, concurrency/2), thumbRequests)
t.Logf("media thumb load: requests=%d concurrency=%d bytes=%d wall=%s throughput=%.1f req/s p50=%s p95=%s p99=%s errors=%d",
thumbRequests,
max(1, concurrency/2),
thumbSize,
thumbResult.wall,
float64(thumbRequests)/thumbResult.wall.Seconds(),
thumbResult.p50,
thumbResult.p95,
thumbResult.p99,
thumbResult.errors,
)
if thumbResult.errors > 0 {
t.Fatalf("thumb load errors=%d", thumbResult.errors)
}
}
}
type getFileLoadResult struct {
wall time.Duration
bytes int64
errors int64
p50 time.Duration
p95 time.Duration
p99 time.Duration
}
func runGetFileLoad(ctx context.Context, t *testing.T, svc *filesapp.Service, locationKey string, totalSize int64, chunkSize, concurrency, requests int) getFileLoadResult {
t.Helper()
perWorkerLat := make([][]time.Duration, concurrency)
var counter atomic.Int64
var readBytes atomic.Int64
var errs atomic.Int64
start := time.Now()
var wg sync.WaitGroup
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
lat := make([]time.Duration, 0, requests/concurrency+1)
for {
n := int(counter.Add(1))
if n > requests {
break
}
offset := int64((n - 1) * chunkSize)
if totalSize > 0 {
offset %= totalSize
}
t0 := time.Now()
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: locationKey,
Offset: offset,
Limit: chunkSize,
})
lat = append(lat, time.Since(t0))
if err != nil || !ok {
errs.Add(1)
continue
}
readBytes.Add(int64(len(chunk.Bytes)))
}
perWorkerLat[worker] = lat
}(w)
}
wg.Wait()
wall := time.Since(start)
latencies := flattenDurations(perWorkerLat)
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
return getFileLoadResult{
wall: wall,
bytes: readBytes.Load(),
errors: errs.Load(),
p50: percentile(latencies, 50),
p95: percentile(latencies, 95),
p99: percentile(latencies, 99),
}
}
func flattenDurations(values [][]time.Duration) []time.Duration {
var total int
for _, v := range values {
total += len(v)
}
out := make([]time.Duration, 0, total)
for _, v := range values {
out = append(out, v...)
}
return out
}
type patternReader struct {
remaining int64
next byte
}
func newPatternReader(n int64) *patternReader {
return &patternReader{remaining: n, next: 17}
}
func (r *patternReader) Read(p []byte) (int, error) {
if r.remaining <= 0 {
return 0, io.EOF
}
if int64(len(p)) > r.remaining {
p = p[:r.remaining]
}
for i := range p {
p[i] = r.next
r.next += 31
}
r.remaining -= int64(len(p))
return len(p), nil
}