feat: verify bounded simultaneous schedule execution
This commit is contained in:
351
sdk/go/cmd/orbit-schedule-load/measure.go
Normal file
351
sdk/go/cmd/orbit-schedule-load/measure.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
// schedulePayload 冻结运行和计划序号;精确字段集合用于检测错配输入。
|
||||
type schedulePayload struct {
|
||||
RunID string `json:"run_id"` // RunID 本次测量身份,不是业务幂等保证。
|
||||
Schedule int `json:"schedule"` // Schedule 从零开始的计划序号。
|
||||
}
|
||||
|
||||
func decodePayload(raw json.RawMessage) (schedulePayload, bool) {
|
||||
// map中的RawMessage保留整数文本,不接受缺字段、null和非精确字段名。
|
||||
var fields map[string]json.RawMessage
|
||||
var out schedulePayload
|
||||
if json.Unmarshal(raw, &fields) != nil || len(fields) != 2 {
|
||||
return out, false
|
||||
}
|
||||
run, hasRun := fields["run_id"]
|
||||
index, hasIndex := fields["schedule"]
|
||||
if !hasRun || !hasIndex || string(run) == "null" || string(index) == "null" || json.Unmarshal(run, &out.RunID) != nil || json.Unmarshal(index, &out.Schedule) != nil {
|
||||
return schedulePayload{}, false
|
||||
}
|
||||
// 按原文计数,拒绝重复键;字段次序可以不同。
|
||||
var keys []string
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
return schedulePayload{}, false
|
||||
}
|
||||
for decoder.More() {
|
||||
k, err := decoder.Token()
|
||||
if err != nil {
|
||||
return schedulePayload{}, false
|
||||
}
|
||||
keys = append(keys, k.(string))
|
||||
var value json.RawMessage
|
||||
if decoder.Decode(&value) != nil {
|
||||
return schedulePayload{}, false
|
||||
}
|
||||
}
|
||||
return out, len(keys) == 2
|
||||
}
|
||||
|
||||
func handlerName(runID string) string { return "load.schedule." + runID }
|
||||
func operationKey(runID string, index int, op string) string {
|
||||
return fmt.Sprintf("schedule-%s-%02d-%s", runID, index, op)
|
||||
}
|
||||
func businessKey(runID string, index int) string { return operationKey(runID, index, "task") }
|
||||
|
||||
type scheduleAPI interface {
|
||||
GetQueue(context.Context, string) (orbit.Queue, error)
|
||||
CreateSchedule(context.Context, string, orbit.CreateScheduleRequest) (orbit.ScheduleResult, error)
|
||||
GetSchedule(context.Context, string) (orbit.Schedule, error)
|
||||
ResumeSchedule(context.Context, string, string, orbit.ControlScheduleRequest) (orbit.ScheduleResult, error)
|
||||
PauseSchedule(context.Context, string, string, orbit.ControlScheduleRequest) (orbit.ScheduleResult, error)
|
||||
ListScheduleOccurrences(context.Context, string, orbit.SchedulePageRequest) (orbit.ScheduleOccurrencePage, error)
|
||||
GetTask(context.Context, string) (orbit.Task, error)
|
||||
ListTaskAttempts(context.Context, string, orbit.HistoryPageRequest) (orbit.AttemptPage, error)
|
||||
GetTaskSource(context.Context, string) (orbit.TaskSource, error)
|
||||
}
|
||||
|
||||
func scheduleDefinition(cfg config, runID string, index int, start time.Time) orbit.ScheduleDefinition {
|
||||
end := start.Add(time.Duration(cfg.Requests) * cfg.Interval)
|
||||
payload, _ := json.Marshal(schedulePayload{RunID: runID, Schedule: index})
|
||||
return orbit.ScheduleDefinition{
|
||||
Rule: orbit.ScheduleRule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, EndAt: &end, IntervalMS: uint64(cfg.Interval.Milliseconds())},
|
||||
Policy: orbit.SchedulePolicy{Misfire: "CATCH_UP", Overlap: "PARALLEL", CatchupLimit: 100, CatchupWindowMS: uint64((cfg.RunTimeout + time.Duration(cfg.Requests)*cfg.Interval + cfg.StartDelay).Milliseconds()), GraceMS: 1000},
|
||||
Task: orbit.SubmitTaskRequest{QueueID: cfg.QueueID, Handler: handlerName(runID), HandlerVersion: "v1", PayloadVersion: "v1", BusinessKey: businessKey(runID, index), Payload: payload, TaskTimeoutMS: uint64(cfg.RunTimeout.Milliseconds()), RecoverySafety: orbit.RecoveryReconcileOnly, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: uint64(cfg.RunTimeout.Milliseconds())}},
|
||||
}
|
||||
}
|
||||
|
||||
func measure(parent context.Context, cfg config, api *orbit.Client, runID string) (r scheduleReport) {
|
||||
r = newReport(cfg, runID)
|
||||
ctx, cancel := context.WithTimeout(parent, cfg.RunTimeout)
|
||||
defer cancel()
|
||||
var workers []*orbit.Worker
|
||||
var stopWorkers context.CancelFunc
|
||||
var done chan error
|
||||
log := newInvocationLog(cfg.Requests * cfg.Schedules)
|
||||
observations := newObservations(cfg, runID)
|
||||
// 所有返回路径都执行有界收尾;超时后的暂停使用独立上下文,结果不明不换键重试。
|
||||
defer func() {
|
||||
if stopWorkers != nil {
|
||||
stopWorkers()
|
||||
for range workers {
|
||||
if err := <-done; err != nil {
|
||||
r.Errors["WORKER_STOP"]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, w := range workers {
|
||||
s := w.Stats()
|
||||
r.WorkerStarted += s.Started
|
||||
r.Reconnects += s.Reconnects
|
||||
r.Renewals += s.RenewalsConfirmed
|
||||
}
|
||||
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan string, len(r.Plans))
|
||||
for i := range r.Plans {
|
||||
wg.Add(1)
|
||||
go func(p *planReport) {
|
||||
defer wg.Done()
|
||||
if validID(p.ID) {
|
||||
codes <- pauseSchedule(cleanup, cfg, api, runID, p)
|
||||
}
|
||||
}(&r.Plans[i])
|
||||
}
|
||||
wg.Wait()
|
||||
stop()
|
||||
close(codes)
|
||||
for code := range codes {
|
||||
if code != "" {
|
||||
r.Errors[code]++
|
||||
}
|
||||
}
|
||||
observations.summarize(&r)
|
||||
log.summarize(&r, observations)
|
||||
if ctx.Err() != nil {
|
||||
r.Errors["RUN_"+classify(ctx.Err())]++
|
||||
}
|
||||
r = r.finish()
|
||||
}()
|
||||
call, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
queue, err := api.GetQueue(call, cfg.QueueID)
|
||||
stop()
|
||||
if err != nil {
|
||||
r.Errors["PREFLIGHT_"+classify(err)]++
|
||||
return
|
||||
}
|
||||
r.Queue = &queue
|
||||
if queue.ID != cfg.QueueID || !validID(queue.NamespaceID) || queue.Paused || queue.MaxConcurrency == 0 || queue.RatePerSecond == 0 {
|
||||
r.Errors["QUEUE_UNAVAILABLE"]++
|
||||
return
|
||||
}
|
||||
r.ScheduleStart = time.Now().UTC().Truncate(time.Microsecond).Add(cfg.StartDelay)
|
||||
r.ScheduleEnd = r.ScheduleStart.Add(time.Duration(cfg.Requests) * cfg.Interval)
|
||||
// 在恢复任何计划之前完成全部创建;计划有限终止,即使进程被强杀也不会无限触发。
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < cfg.Schedules; i++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
definition := scheduleDefinition(cfg, runID, i, r.ScheduleStart)
|
||||
key := operationKey(runID, i, "create")
|
||||
p := planReport{Index: i, CreateKey: key}
|
||||
r.Plans = append(r.Plans, p)
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
res, e := api.CreateSchedule(call, key, orbit.CreateScheduleRequest{Name: key, Paused: true, Definition: definition})
|
||||
stop()
|
||||
if e != nil {
|
||||
r.Errors["CREATE_"+classify(e)]++
|
||||
return
|
||||
}
|
||||
p.ID = res.Schedule.ID
|
||||
p.CalendarVersion = res.Schedule.Current.Definition.Rule.CalendarVersion
|
||||
p.EffectiveAt = res.Schedule.Current.EffectiveAt
|
||||
r.Plans[i] = p
|
||||
if res.Deduplicated || !validID(p.ID) || seen[p.ID] || !matchesSchedule(res.Schedule, cfg, runID, i, queue.NamespaceID, r.ScheduleStart) || !res.Schedule.Paused || res.Schedule.Current.EffectiveAt.After(r.ScheduleStart) {
|
||||
r.Errors["CREATE_MISMATCH"]++
|
||||
return
|
||||
}
|
||||
seen[p.ID] = true
|
||||
observations.addPlan(p.ID, i, r.ScheduleStart, queue.NamespaceID)
|
||||
}
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
w, e := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: cfg.QueueID, Capacity: uint32(cfg.Capacity), ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{log.handler(ctx, cfg, runID)}})
|
||||
if e != nil {
|
||||
r.Errors["WORKER_CONFIG"]++
|
||||
return
|
||||
}
|
||||
workers = append(workers, w)
|
||||
}
|
||||
workerCtx, workerCancel := context.WithCancel(ctx)
|
||||
stopWorkers = workerCancel
|
||||
done = make(chan error, len(workers))
|
||||
for _, w := range workers {
|
||||
go func(w *orbit.Worker) {
|
||||
err := w.Run(workerCtx)
|
||||
if workerCtx.Err() == nil {
|
||||
cancel()
|
||||
}
|
||||
done <- err
|
||||
}(w)
|
||||
}
|
||||
for i := range r.Plans {
|
||||
p := &r.Plans[i]
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
current, e := api.GetSchedule(call, p.ID)
|
||||
stop()
|
||||
if e != nil {
|
||||
r.Errors["RESUME_READ_"+classify(e)]++
|
||||
return
|
||||
}
|
||||
if !time.Now().Before(r.ScheduleStart) || current.ID != p.ID || !matchesSchedule(current, cfg, runID, i, queue.NamespaceID, r.ScheduleStart) || !current.Paused || !sameVersion(current, *p) {
|
||||
r.Errors["SETUP_LATE_OR_CHANGED"]++
|
||||
return
|
||||
}
|
||||
p.ResumeKey = operationKey(runID, i, "resume")
|
||||
p.ResumeRevision = current.Revision
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
res, e := api.ResumeSchedule(call, p.ID, p.ResumeKey, orbit.ControlScheduleRequest{ExpectedRevision: current.Revision, Reason: "start bounded schedule verification"})
|
||||
stop()
|
||||
if e != nil {
|
||||
r.Errors["RESUME_"+classify(e)]++
|
||||
return
|
||||
}
|
||||
if res.Deduplicated || res.Schedule.ID != p.ID || res.Schedule.Paused || !matchesSchedule(res.Schedule, cfg, runID, i, queue.NamespaceID, r.ScheduleStart) || !res.Schedule.UpdatedAt.Before(r.ScheduleStart) || !sameVersion(res.Schedule, *p) {
|
||||
r.Errors["RESUME_MISMATCH_OR_LATE"]++
|
||||
return
|
||||
}
|
||||
p.Resumed = true
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
all := true
|
||||
for i := range r.Plans {
|
||||
p := &r.Plans[i]
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
page, e := api.ListScheduleOccurrences(call, p.ID, orbit.SchedulePageRequest{Limit: 100})
|
||||
stop()
|
||||
if e != nil {
|
||||
r.Errors["OCCURRENCES_"+classify(e)]++
|
||||
return
|
||||
}
|
||||
if code := observations.ingest(p.ID, page); code != "" {
|
||||
r.Errors[code]++
|
||||
return
|
||||
}
|
||||
complete, code := observations.verify(ctx, api, p.ID)
|
||||
if code != "" {
|
||||
r.Errors[code]++
|
||||
return
|
||||
}
|
||||
if complete {
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
current, e := api.GetSchedule(call, p.ID)
|
||||
stop()
|
||||
if e != nil {
|
||||
r.Errors["FINAL_READ_"+classify(e)]++
|
||||
return
|
||||
}
|
||||
if current.ID != p.ID || !matchesSchedule(current, cfg, runID, i, queue.NamespaceID, r.ScheduleStart) || current.Paused || !sameVersion(current, *p) {
|
||||
r.Errors["FINAL_SCHEDULE_MISMATCH"]++
|
||||
return
|
||||
}
|
||||
p.Exhausted = current.CheckAt == nil && current.NextFireAt == nil && current.ProcessingVersion == 1
|
||||
}
|
||||
all = all && complete && p.Exhausted
|
||||
}
|
||||
if all {
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(cfg.PollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func matchesSchedule(s orbit.Schedule, cfg config, runID string, index int, namespace string, start time.Time) bool {
|
||||
want := scheduleDefinition(cfg, runID, index, start)
|
||||
got := s.Current.Definition
|
||||
// 服务端冻结具体时区快照标识;不把API版本v1误当作时区数据版本。
|
||||
if !validCalendarVersion(got.Rule.CalendarVersion) {
|
||||
return false
|
||||
}
|
||||
got.Rule.CalendarVersion = ""
|
||||
return s.NamespaceID == namespace && s.QueueID == cfg.QueueID && s.Name == operationKey(runID, index, "create") && s.CurrentVersion == 1 && s.Current.Version == 1 && s.Revision > 0 && !s.Current.EffectiveAt.IsZero() && sameDefinition(got, want)
|
||||
}
|
||||
|
||||
func sameDefinition(a, b orbit.ScheduleDefinition) bool {
|
||||
// 原始JSON可有空白差异;模板和结果保持精确整数,不经float64。
|
||||
pa, ok := decodePayload(a.Task.Payload)
|
||||
pb, ok2 := decodePayload(b.Task.Payload)
|
||||
a.Task.Payload = nil
|
||||
b.Task.Payload = nil
|
||||
return ok && ok2 && pa == pb && reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func pauseSchedule(ctx context.Context, cfg config, api scheduleAPI, runID string, p *planReport) string {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
call, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
current, err := api.GetSchedule(call, p.ID)
|
||||
stop()
|
||||
if err != nil {
|
||||
return "PAUSE_READ_" + classify(err)
|
||||
}
|
||||
if current.ID != p.ID || current.Name != operationKey(runID, p.Index, "create") || current.QueueID != cfg.QueueID {
|
||||
return "PAUSE_IDENTITY_MISMATCH"
|
||||
}
|
||||
if current.Paused {
|
||||
p.PauseConfirmed = true
|
||||
return ""
|
||||
}
|
||||
p.PauseKey = operationKey(runID, p.Index, fmt.Sprintf("pause-%d", attempt))
|
||||
p.PauseRevision = current.Revision
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
res, err := api.PauseSchedule(call, p.ID, p.PauseKey, orbit.ControlScheduleRequest{ExpectedRevision: p.PauseRevision, Reason: "stop bounded schedule verification"})
|
||||
stop()
|
||||
if err == nil {
|
||||
if res.Schedule.ID != p.ID || !res.Schedule.Paused {
|
||||
return "PAUSE_RECEIPT_MISMATCH"
|
||||
}
|
||||
p.PauseConfirmed = true
|
||||
return ""
|
||||
}
|
||||
var apiErr *orbit.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" || apiErr.StatusCode != 409 {
|
||||
return "PAUSE_" + classify(err)
|
||||
}
|
||||
timer := time.NewTimer(25 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return "PAUSE_" + classify(ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return "PAUSE_CONFLICT_LIMIT"
|
||||
}
|
||||
|
||||
func validCalendarVersion(v string) bool {
|
||||
if len(v) == 0 || len(v) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range v {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func sameVersion(s orbit.Schedule, p planReport) bool {
|
||||
return s.Current.Definition.Rule.CalendarVersion == p.CalendarVersion && s.Current.EffectiveAt.Equal(p.EffectiveAt)
|
||||
}
|
||||
Reference in New Issue
Block a user