Files
cpu-benchmarker-server/lib/store/store.go
Daniel Legt 03b4b55927
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m17s
feat(api): support optional systemInfo in submissions
Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata.

Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format.feat(api): support optional systemInfo in submissions

Extend the submission contract to accept a `systemInfo` object and persist it with each submission, including deep-copy support for `extra` metadata.

Also update client-facing docs and HTTP examples (JSON and multipart) and document that the schema is available at `GET /api/schema`, so clients can reliably implement the updated payload format.
2026-04-17 13:57:55 +03:00

434 lines
9.5 KiB
Go

package store
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"cpu-benchmark-server/lib/model"
"github.com/dgraph-io/badger/v4"
"github.com/google/uuid"
)
const submissionPrefix = "submission:"
type indexedSubmission struct {
submission *model.Submission
searchText string
cpuText string
platform string
threadMode string
intensity int
duration int
}
type Store struct {
db *badger.DB
mu sync.RWMutex
orderedIDs []string
records map[string]*indexedSubmission
}
func Open(path string) (*Store, error) {
opts := badger.DefaultOptions(path).WithLogger(nil)
db, err := badger.Open(opts)
if err != nil {
return nil, err
}
store := &Store{
db: db,
records: make(map[string]*indexedSubmission),
}
if err := store.loadIndex(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.orderedIDs)
}
func (s *Store) SaveSubmission(result model.BenchmarkResult, submitter, platform string, systemInfo *model.SystemInfo) (*model.Submission, error) {
submission := &model.Submission{
SubmissionID: uuid.NewString(),
Submitter: model.NormalizeSubmitter(submitter),
Platform: model.NormalizePlatform(platform),
SubmittedAt: time.Now().UTC(),
SystemInfo: cloneSystemInfo(systemInfo),
BenchmarkResult: result,
}
key := submissionKey(submission.SubmittedAt, submission.SubmissionID)
payload, err := json.Marshal(submission)
if err != nil {
return nil, err
}
if err := s.db.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(key), payload)
}); err != nil {
return nil, err
}
indexed := newIndexedSubmission(submission)
s.mu.Lock()
s.records[submission.SubmissionID] = indexed
s.orderedIDs = append([]string{submission.SubmissionID}, s.orderedIDs...)
s.mu.Unlock()
return model.CloneSubmission(submission), nil
}
func cloneSystemInfo(info *model.SystemInfo) *model.SystemInfo {
if info == nil {
return nil
}
copyInfo := *info
if len(info.Extra) > 0 {
copyInfo.Extra = make(map[string]any, len(info.Extra))
for key, value := range info.Extra {
copyInfo.Extra[key] = value
}
}
return &copyInfo
}
func (s *Store) ListSubmissions(page, pageSize int) ([]model.Submission, int) {
s.mu.RLock()
defer s.mu.RUnlock()
total := len(s.orderedIDs)
start, end, _ := pageBounds(page, pageSize, total)
results := make([]model.Submission, 0, max(0, end-start))
for _, id := range s.orderedIDs[start:end] {
record := s.records[id]
if record == nil {
continue
}
results = append(results, *model.CloneSubmission(record.submission))
}
return results, total
}
func (s *Store) SearchSubmissions(text, cpu, thread, platform, sortBy string, intensity, durationSecs int) []model.Submission {
queryText := normalizeSearchText(text)
cpuText := normalizeSearchText(cpu)
thread = normalizeThreadFilter(thread)
platform = normalizePlatformFilter(platform)
sortBy = normalizeSortOption(sortBy)
s.mu.RLock()
defer s.mu.RUnlock()
results := make([]model.Submission, 0)
for _, id := range s.orderedIDs {
record := s.records[id]
if record == nil {
continue
}
if !matchesSearch(record.searchText, queryText) {
continue
}
if !matchesSearch(record.cpuText, cpuText) {
continue
}
if thread != "" && record.threadMode != thread {
continue
}
if platform != "" && record.platform != platform {
continue
}
if intensity > 0 && record.intensity != intensity {
continue
}
if durationSecs > 0 && record.duration != durationSecs {
continue
}
results = append(results, *model.CloneSubmission(record.submission))
}
sortSubmissions(results, sortBy)
return results
}
func (s *Store) loadIndex() error {
return s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = true
opts.Prefix = []byte(submissionPrefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
payload, err := item.ValueCopy(nil)
if err != nil {
return err
}
var submission model.Submission
if err := json.Unmarshal(payload, &submission); err != nil {
return fmt.Errorf("decode %q: %w", item.Key(), err)
}
s.records[submission.SubmissionID] = newIndexedSubmission(&submission)
s.orderedIDs = append(s.orderedIDs, submission.SubmissionID)
}
return nil
})
}
func newIndexedSubmission(submission *model.Submission) *indexedSubmission {
return &indexedSubmission{
submission: model.CloneSubmission(submission),
searchText: buildSearchText(submission),
cpuText: normalizeSearchText(submission.CPUInfo.BrandString),
platform: model.NormalizePlatform(submission.Platform),
threadMode: normalizeThreadMode(submission.Config.MultiCore),
intensity: submission.Config.Intensity,
duration: submission.Config.DurationSecs,
}
}
func buildSearchText(submission *model.Submission) string {
parts := []string{
submission.SubmissionID,
submission.Submitter,
submission.Platform,
submission.CPUInfo.BrandString,
submission.CPUInfo.VendorID,
model.ThreadModeLabel(submission.Config.MultiCore),
strconv.Itoa(submission.Config.DurationSecs),
strconv.Itoa(submission.Config.Intensity),
strconv.Itoa(submission.CPUInfo.PhysicalCores),
strconv.Itoa(submission.CPUInfo.LogicalCores),
strconv.FormatInt(submission.Duration, 10),
strconv.FormatInt(submission.TotalOps, 10),
strconv.FormatInt(submission.Score, 10),
fmt.Sprintf("%.4f", submission.MOpsPerSec),
}
for _, feature := range submission.CPUInfo.SupportedFeatures {
parts = append(parts, feature)
}
for _, result := range submission.CoreResults {
parts = append(parts,
strconv.Itoa(result.LogicalID),
result.CoreType,
strconv.FormatInt(result.TotalOps, 10),
fmt.Sprintf("%.4f", result.MOpsPerSec),
)
}
return normalizeSearchText(strings.Join(parts, " "))
}
func submissionKey(timestamp time.Time, submissionID string) string {
reversed := math.MaxInt64 - timestamp.UTC().UnixNano()
return fmt.Sprintf("%s%019d:%s", submissionPrefix, reversed, submissionID)
}
func normalizeSearchText(value string) string {
return strings.Join(strings.Fields(strings.ToLower(value)), " ")
}
func matchesSearch(target, query string) bool {
if query == "" {
return true
}
for _, token := range strings.Fields(query) {
if !strings.Contains(target, token) {
return false
}
}
return true
}
func normalizeThreadFilter(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case "single":
return "single"
case "multi":
return "multi"
default:
return ""
}
}
func normalizeThreadMode(multiCore bool) string {
if multiCore {
return "multi"
}
return "single"
}
func normalizePlatformFilter(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case "windows":
return "windows"
case "linux":
return "linux"
case "macos":
return "macos"
default:
return ""
}
}
func normalizeSortOption(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "newest":
return "newest"
case "oldest":
return "oldest"
case "score_desc":
return "score_desc"
case "score_asc":
return "score_asc"
case "mops_desc":
return "mops_desc"
case "mops_asc":
return "mops_asc"
default:
return "newest"
}
}
func sortSubmissions(submissions []model.Submission, sortBy string) {
switch sortBy {
case "oldest":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].SubmittedAt.Equal(submissions[j].SubmittedAt) {
return submissions[i].SubmissionID < submissions[j].SubmissionID
}
return submissions[i].SubmittedAt.Before(submissions[j].SubmittedAt)
})
case "score_desc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].Score == submissions[j].Score {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].Score > submissions[j].Score
})
case "score_asc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].Score == submissions[j].Score {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].Score < submissions[j].Score
})
case "mops_desc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].MOpsPerSec == submissions[j].MOpsPerSec {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].MOpsPerSec > submissions[j].MOpsPerSec
})
case "mops_asc":
sort.SliceStable(submissions, func(i, j int) bool {
if submissions[i].MOpsPerSec == submissions[j].MOpsPerSec {
return submissions[i].SubmittedAt.After(submissions[j].SubmittedAt)
}
return submissions[i].MOpsPerSec < submissions[j].MOpsPerSec
})
}
}
func pageBounds(page, pageSize, total int) (int, int, int) {
if pageSize <= 0 {
pageSize = 50
}
totalPages := totalPages(total, pageSize)
if totalPages == 0 {
return 0, 0, 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * pageSize
end := min(total, start+pageSize)
return start, end, page
}
func totalPages(total, pageSize int) int {
if total == 0 || pageSize <= 0 {
return 0
}
pages := total / pageSize
if total%pageSize != 0 {
pages++
}
return pages
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}