All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m17s
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.
434 lines
9.5 KiB
Go
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 ©Info
|
|
}
|
|
|
|
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
|
|
}
|