chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunArchive struct {
|
||||
Version int `json:"version"`
|
||||
Run RunSummary `json:"run"`
|
||||
Rounds []RoundStats `json:"rounds"`
|
||||
}
|
||||
|
||||
type RunSummary struct {
|
||||
SupplyObservation json.RawMessage `json:"supplyObservation,omitempty"`
|
||||
SyncType string `json:"syncType,omitempty"`
|
||||
RunID string `json:"runId"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
SuccessCount int64 `json:"successCount"`
|
||||
FailedCount int64 `json:"failedCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
}
|
||||
|
||||
type RoundStats struct {
|
||||
RunID string `json:"runId"`
|
||||
Round int `json:"round"`
|
||||
EnqueuedCount int64 `json:"enqueuedCount"`
|
||||
ActiveCount int64 `json:"activeCount"`
|
||||
NextRoundCount int64 `json:"nextRoundCount"`
|
||||
SuccessCount int64 `json:"successCount"`
|
||||
FailedCount int64 `json:"failedCount"`
|
||||
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
|
||||
BatchCount int64 `json:"batchCount"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
ErrorCounts map[string]int `json:"errorCounts"`
|
||||
}
|
||||
|
||||
func Load(path string) (RunArchive, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return RunArchive{}, err
|
||||
}
|
||||
var a RunArchive
|
||||
if err := json.Unmarshal(body, &a); err != nil {
|
||||
return RunArchive{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if err := a.Validate(); err != nil {
|
||||
return RunArchive{}, fmt.Errorf("validate %s: %w", path, err)
|
||||
}
|
||||
if err := ValidateFilename(path, a.Run.RunID); err != nil {
|
||||
return RunArchive{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func ValidateFilename(path, runID string) error {
|
||||
want := "run-" + runID + ".json"
|
||||
if filepath.Base(path) != want {
|
||||
return fmt.Errorf("archive filename %q does not match RunID; expected %q", filepath.Base(path), want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *RunArchive) Validate() error {
|
||||
if a.Version != 1 {
|
||||
return fmt.Errorf("unsupported version %d", a.Version)
|
||||
}
|
||||
if a.Run.RunID == "" || a.Run.Operation == "" {
|
||||
return fmt.Errorf("runId and operation are required")
|
||||
}
|
||||
if a.Run.Status != "completed" && a.Run.Status != "completed_with_failures" {
|
||||
return fmt.Errorf("run is not terminal: %s", a.Run.Status)
|
||||
}
|
||||
if len(a.Rounds) == 0 {
|
||||
return fmt.Errorf("rounds must be non-empty")
|
||||
}
|
||||
copyRounds := append([]RoundStats(nil), a.Rounds...)
|
||||
sort.Slice(copyRounds, func(i, j int) bool { return copyRounds[i].Round < copyRounds[j].Round })
|
||||
var success, failed int64
|
||||
for i, r := range copyRounds {
|
||||
if r.Round != i+1 {
|
||||
return fmt.Errorf("round sequence is not contiguous at %d", r.Round)
|
||||
}
|
||||
if r.RunID != "" && r.RunID != a.Run.RunID {
|
||||
return fmt.Errorf("round %d runId does not match root runId", r.Round)
|
||||
}
|
||||
if r.EnqueuedCount < 0 || r.ActiveCount < 0 || r.NextRoundCount < 0 || r.SuccessCount < 0 || r.FailedCount < 0 || r.UpstreamAttemptCount < 0 || r.BatchCount < 0 {
|
||||
return fmt.Errorf("round %d contains negative count", r.Round)
|
||||
}
|
||||
if r.EnqueuedCount != r.SuccessCount+r.FailedCount+r.NextRoundCount {
|
||||
return fmt.Errorf("round %d count does not close", r.Round)
|
||||
}
|
||||
if _, err := parseTime(r.StartedAt); err != nil {
|
||||
return fmt.Errorf("round %d invalid startedAt: %w", r.Round, err)
|
||||
}
|
||||
if _, err := parseTime(r.CompletedAt); err != nil {
|
||||
return fmt.Errorf("round %d invalid completedAt: %w", r.Round, err)
|
||||
}
|
||||
success += r.SuccessCount
|
||||
failed += r.FailedCount
|
||||
}
|
||||
if a.Run.SuccessCount != success || a.Run.FailedCount != failed {
|
||||
return fmt.Errorf("run totals do not match rounds: run success=%d failed=%d, rounds success=%d failed=%d", a.Run.SuccessCount, a.Run.FailedCount, success, failed)
|
||||
}
|
||||
if _, err := parseTime(a.Run.CreatedAt); err != nil {
|
||||
return fmt.Errorf("invalid createdAt: %w", err)
|
||||
}
|
||||
if _, err := parseTime(a.Run.CompletedAt); err != nil {
|
||||
return fmt.Errorf("invalid completedAt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a RunArchive) TotalCount() int64 { return a.Run.SuccessCount + a.Run.FailedCount }
|
||||
func (a RunArchive) SuccessRate() float64 {
|
||||
if a.TotalCount() == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a.Run.SuccessCount) / float64(a.TotalCount()) * 100
|
||||
}
|
||||
func (a RunArchive) Duration() time.Duration {
|
||||
start, err1 := parseTime(a.Run.CreatedAt)
|
||||
end, err2 := parseTime(a.Run.CompletedAt)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0
|
||||
}
|
||||
return end.Sub(start)
|
||||
}
|
||||
func (r RoundStats) SuccessRate() float64 {
|
||||
if r.EnqueuedCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.SuccessCount) / float64(r.EnqueuedCount) * 100
|
||||
}
|
||||
|
||||
func (r RoundStats) Duration() time.Duration {
|
||||
start, err1 := parseTime(r.StartedAt)
|
||||
end, err2 := parseTime(r.CompletedAt)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0
|
||||
}
|
||||
return end.Sub(start)
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999", "2006-01-02 15:04:05"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unsupported time %q", value)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package archive
|
||||
|
||||
import "testing"
|
||||
|
||||
func validArchive() RunArchive {
|
||||
return RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []RoundStats{
|
||||
{RunID: "run-1", Round: 1, EnqueuedCount: 186397, ActiveCount: 0, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, UpstreamAttemptCount: 0, BatchCount: 1872, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
|
||||
{RunID: "run-1", Round: 2, EnqueuedCount: 9023, ActiveCount: 0, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, UpstreamAttemptCount: 0, BatchCount: 92, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
|
||||
{RunID: "run-1", Round: 3, EnqueuedCount: 9000, ActiveCount: 0, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, UpstreamAttemptCount: 0, BatchCount: 90, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestValidateThreeRounds(t *testing.T) {
|
||||
a := validArchive()
|
||||
if err := a.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Rounds) != 3 || a.Run.FailedCount != 10504 {
|
||||
t.Fatalf("unexpected archive: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsEmptyRounds(t *testing.T) {
|
||||
a := RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed"}}
|
||||
if err := a.Validate(); err == nil {
|
||||
t.Fatal("expected empty rounds to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnclosedRound(t *testing.T) {
|
||||
a := validArchive()
|
||||
a.Rounds[0].NextRoundCount++
|
||||
if err := a.Validate(); err == nil {
|
||||
t.Fatal("expected count closure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilenameRequiresExactRunID(t *testing.T) {
|
||||
if err := ValidateFilename(`/archive/run-full-run-id.json`, "full-run-id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateFilename(`/archive/run-other-id.json`, "full-run-id"); err == nil {
|
||||
t.Fatal("expected mismatched filename to be rejected")
|
||||
}
|
||||
if err := ValidateFilename(`/archive/action-full-run-id.json`, "full-run-id"); err == nil {
|
||||
t.Fatal("expected non-run filename to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package archive
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type SupplyWait struct {
|
||||
Count int64 `json:"count"`
|
||||
TotalMS int64 `json:"totalMs"`
|
||||
LongestMS int64 `json:"longestMs"`
|
||||
}
|
||||
type SupplyChannel struct {
|
||||
Key string `json:"chanKey"`
|
||||
Startup SupplyWait `json:"startupWait"`
|
||||
Confirmed SupplyWait `json:"confirmedWait"`
|
||||
Unconfirmed SupplyWait `json:"unconfirmedIdle"`
|
||||
UnknownMS int64 `json:"unknownMs"`
|
||||
Stages map[string]int64 `json:"producerStageMs"`
|
||||
}
|
||||
type SupplySummary struct {
|
||||
UnknownMS int64 `json:"unknownChannelMs"`
|
||||
UnconfirmedMS int64 `json:"unconfirmedChannelMs"`
|
||||
ConsumerMS int64 `json:"consumerStartupChannelMs"`
|
||||
Targets int `json:"targetChannels"`
|
||||
Valid int `json:"validChannels"`
|
||||
Confirmed int `json:"confirmedChannels"`
|
||||
Unknown int `json:"unknownChannels"`
|
||||
TotalMS int64 `json:"confirmedChannelMs"`
|
||||
LongestMS int64 `json:"longestMs"`
|
||||
}
|
||||
type Supply struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ResolutionMS int `json:"resolutionMs"`
|
||||
Partial bool `json:"partial"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Summary SupplySummary `json:"summary"`
|
||||
Channels []SupplyChannel `json:"channels"`
|
||||
}
|
||||
|
||||
func (r RunSummary) Supply() (*Supply, bool) {
|
||||
if len(r.SupplyObservation) == 0 || string(r.SupplyObservation) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var s Supply
|
||||
if json.Unmarshal(r.SupplyObservation, &s) != nil || s.SchemaVersion != 1 || s.ResolutionMS != 1000 || s.Summary.Targets < 0 || s.Summary.Valid < 0 || s.Summary.Valid > s.Summary.Targets || s.Summary.TotalMS < 0 || s.Summary.LongestMS < 0 {
|
||||
return nil, true
|
||||
}
|
||||
return &s, true
|
||||
}
|
||||
Reference in New Issue
Block a user