package services import ( "context" "encoding/json" "fmt" "io" "math" "os" "path/filepath" "sort" "strings" "time" "go.etcd.io/bbolt" ) var storageSpeedTestsBucket = []byte("storage_speed_tests") const ( StorageSpeedModeSmall = "small" StorageSpeedModeBig = "big" StorageSpeedModeMixed = "mixed" StorageSpeedModeCustom = "custom" StorageSpeedStatusRunning = "running" StorageSpeedStatusDone = "done" StorageSpeedStatusFailed = "failed" ) type StorageSpeedTest struct { ID string `json:"id"` BackendID string `json:"backendId"` BackendName string `json:"backendName"` Mode string `json:"mode"` Status string `json:"status"` Stage string `json:"stage"` ProgressPercent int `json:"progressPercent"` CustomFileCount int `json:"customFileCount,omitempty"` CustomFileSizeMB float64 `json:"customFileSizeMb,omitempty"` StartedAt time.Time `json:"startedAt"` FinishedAt time.Time `json:"finishedAt,omitempty"` BytesWritten int64 `json:"bytesWritten"` BytesRead int64 `json:"bytesRead"` FilesWritten int `json:"filesWritten"` WriteDurationMS int64 `json:"writeDurationMs"` ReadDurationMS int64 `json:"readDurationMs"` DeleteDurationMS int64 `json:"deleteDurationMs"` Error string `json:"error,omitempty"` } func (t StorageSpeedTest) ModeLabel() string { switch t.Mode { case StorageSpeedModeSmall: return "Many small files" case StorageSpeedModeBig: return "One big file" case StorageSpeedModeMixed: return "Average mix" case StorageSpeedModeCustom: return "Custom" default: return t.Mode } } func (t StorageSpeedTest) StartedLabel() string { if t.StartedAt.IsZero() { return "" } return t.StartedAt.Format("Jan 2, 15:04:05") } func (t StorageSpeedTest) FinishedLabel() string { if t.FinishedAt.IsZero() { return "Still running" } return t.FinishedAt.Format("Jan 2, 15:04:05") } func (t StorageSpeedTest) TotalSizeLabel() string { return FormatMegabytesFromBytes(max(t.BytesWritten, t.BytesRead)) } func (t StorageSpeedTest) WriteSpeedLabel() string { return speedLabel(t.BytesWritten, t.WriteDurationMS) } func (t StorageSpeedTest) ReadSpeedLabel() string { return speedLabel(t.BytesRead, t.ReadDurationMS) } func speedLabel(bytes int64, durationMS int64) string { if bytes <= 0 || durationMS <= 0 { return "n/a" } mb := float64(bytes) / 1024 / 1024 seconds := float64(durationMS) / 1000 value := math.Round((mb/seconds)*100) / 100 return fmt.Sprintf("%.2f MB/s", value) } func (s *StorageService) StartSpeedTest(backendID, mode string) (StorageSpeedTest, error) { return s.StartSpeedTestWithOptions(backendID, StorageSpeedTestOptions{Mode: mode}) } type StorageSpeedTestOptions struct { Mode string CustomFileCount int CustomFileSizeMB float64 } func (s *StorageService) StartSpeedTestWithOptions(backendID string, options StorageSpeedTestOptions) (StorageSpeedTest, error) { cfg, err := s.BackendConfig(backendID) if err != nil { return StorageSpeedTest{}, err } if !cfg.Enabled { return StorageSpeedTest{}, fmt.Errorf("storage backend is disabled") } if !cfg.LastTestSuccess { return StorageSpeedTest{}, fmt.Errorf("run a successful connection test before testing speed") } mode := normalizeSpeedTestMode(options.Mode) if mode == StorageSpeedModeCustom { if err := validateCustomSpeedTest(options.CustomFileCount, options.CustomFileSizeMB); err != nil { return StorageSpeedTest{}, err } } test := StorageSpeedTest{ ID: randomID(10), BackendID: cfg.ID, BackendName: cfg.Name, Mode: mode, Status: StorageSpeedStatusRunning, Stage: "queued", CustomFileCount: options.CustomFileCount, CustomFileSizeMB: options.CustomFileSizeMB, StartedAt: time.Now().UTC(), } if err := s.saveSpeedTest(test); err != nil { return StorageSpeedTest{}, err } return test, nil } func (s *StorageService) RunSpeedTest(ctx context.Context, testID string) { test, err := s.speedTest(testID) if err != nil { return } if err := s.runSpeedTest(ctx, &test); err != nil { test.Status = StorageSpeedStatusFailed test.Error = err.Error() test.FinishedAt = time.Now().UTC() if test.Stage == "" || test.Stage == "queued" { test.Stage = "failed" } _ = s.saveSpeedTest(test) return } test.Status = StorageSpeedStatusDone test.Stage = "complete" test.ProgressPercent = 100 test.FinishedAt = time.Now().UTC() _ = s.saveSpeedTest(test) } func (s *StorageService) ListSpeedTests(backendID string, limit int) ([]StorageSpeedTest, error) { var tests []StorageSpeedTest err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(storageSpeedTestsBucket) if bucket == nil { return nil } return bucket.ForEach(func(_, value []byte) error { var test StorageSpeedTest if err := json.Unmarshal(value, &test); err != nil { return err } if backendID == "" || test.BackendID == backendID { tests = append(tests, test) } return nil }) }) if err != nil { return nil, err } sort.Slice(tests, func(i, j int) bool { return tests[i].StartedAt.After(tests[j].StartedAt) }) if limit > 0 && len(tests) > limit { tests = tests[:limit] } return tests, nil } func (s *StorageService) speedTest(id string) (StorageSpeedTest, error) { var test StorageSpeedTest err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(storageSpeedTestsBucket).Get([]byte(id)) if data == nil { return fmt.Errorf("speed test not found") } return json.Unmarshal(data, &test) }) return test, err } func (s *StorageService) saveSpeedTest(test StorageSpeedTest) error { data, err := json.Marshal(test) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(storageSpeedTestsBucket).Put([]byte(test.ID), data) }) } func (s *StorageService) runSpeedTest(ctx context.Context, test *StorageSpeedTest) error { backend, err := s.Backend(test.BackendID) if err != nil { return err } files, err := createSpeedTestFiles(test) if err != nil { return err } defer os.RemoveAll(files.Root) keys := make([]string, 0, len(files.Files)) defer func() { for _, key := range keys { _ = backend.Delete(context.Background(), key) } }() writeStart := time.Now() for i, file := range files.Files { key := fmt.Sprintf(".warpbox-speed-test/%s/%03d.bin", test.ID, i) source, err := os.Open(file.Path) if err != nil { return err } err = backend.Put(ctx, key, source, file.Size, "application/octet-stream") source.Close() if err != nil { return err } keys = append(keys, key) test.BytesWritten += file.Size test.FilesWritten++ updateSpeedProgress(test, "writing", i+1, len(files.Files), 0, 45) _ = s.saveSpeedTest(*test) } test.WriteDurationMS = time.Since(writeStart).Milliseconds() _ = s.saveSpeedTest(*test) readStart := time.Now() for i, key := range keys { object, err := backend.Get(ctx, key) if err != nil { return err } read, err := io.Copy(io.Discard, object.Body) object.Body.Close() if err != nil { return err } test.BytesRead += read updateSpeedProgress(test, "reading", i+1, len(keys), 45, 90) _ = s.saveSpeedTest(*test) } test.ReadDurationMS = time.Since(readStart).Milliseconds() _ = s.saveSpeedTest(*test) deleteStart := time.Now() for i, key := range keys { if err := backend.Delete(ctx, key); err != nil { return err } updateSpeedProgress(test, "cleaning up", i+1, len(keys), 90, 100) _ = s.saveSpeedTest(*test) } test.DeleteDurationMS = time.Since(deleteStart).Milliseconds() keys = nil return nil } func updateSpeedProgress(test *StorageSpeedTest, stage string, done, total, start, end int) { test.Stage = stage if total <= 0 { test.ProgressPercent = start return } span := end - start progress := start + int(math.Round(float64(span)*float64(done)/float64(total))) if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } test.ProgressPercent = progress } type speedTestFile struct { Path string Size int64 } type speedTestFiles struct { Root string Files []speedTestFile } func createSpeedTestFiles(test *StorageSpeedTest) (speedTestFiles, error) { plan, err := speedTestPlan(test) if err != nil { return speedTestFiles{}, err } root, err := os.MkdirTemp("", "warpbox-speed-test-*") if err != nil { return speedTestFiles{}, err } files := speedTestFiles{Root: root, Files: make([]speedTestFile, 0, len(plan))} for i, size := range plan { path := filepath.Join(root, fmt.Sprintf("%03d.bin", i)) if err := writeMockFile(path, size, byte(65+(i%23))); err != nil { os.RemoveAll(root) return speedTestFiles{}, err } files.Files = append(files.Files, speedTestFile{Path: path, Size: size}) } return files, nil } func speedTestPlan(test *StorageSpeedTest) ([]int64, error) { mode := normalizeSpeedTestMode(test.Mode) if mode == StorageSpeedModeCustom { if err := validateCustomSpeedTest(test.CustomFileCount, test.CustomFileSizeMB); err != nil { return nil, err } size := MegabytesToBytes(test.CustomFileSizeMB) plan := make([]int64, test.CustomFileCount) for i := range plan { plan[i] = size } return plan, nil } return speedTestPlanForMode(mode), nil } func speedTestPlanForMode(mode string) []int64 { mode = normalizeSpeedTestMode(mode) switch mode { case StorageSpeedModeSmall: return repeatedSizes(24, 32*1024) case StorageSpeedModeBig: return repeatedSizes(1, 8*1024*1024) default: sizes := repeatedSizes(8, 64*1024) return append(sizes, repeatedSizes(1, 4*1024*1024)...) } } func repeatedSizes(count int, size int64) []int64 { sizes := make([]int64, 0, count) for i := 0; i < count; i++ { sizes = append(sizes, size) } return sizes } func writeMockFile(path string, size int64, seed byte) error { target, err := os.Create(path) if err != nil { return err } defer target.Close() chunk := make([]byte, 64*1024) for i := range chunk { chunk[i] = seed } remaining := size for remaining > 0 { writeSize := int64(len(chunk)) if remaining < writeSize { writeSize = remaining } if _, err := target.Write(chunk[:int(writeSize)]); err != nil { return err } remaining -= writeSize } return nil } func validateCustomSpeedTest(count int, sizeMB float64) error { if count <= 0 || count > 500 { return fmt.Errorf("custom speed test file count must be between 1 and 500") } if sizeMB <= 0 { return fmt.Errorf("custom speed test file size must be positive") } totalMB := float64(count) * sizeMB if totalMB > 4096 { return fmt.Errorf("custom speed test total size cannot exceed 4096 MB") } return nil } func normalizeSpeedTestMode(mode string) string { switch strings.TrimSpace(mode) { case StorageSpeedModeSmall: return StorageSpeedModeSmall case StorageSpeedModeBig: return StorageSpeedModeBig case StorageSpeedModeCustom: return StorageSpeedModeCustom default: return StorageSpeedModeMixed } }