Files

207 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
"reflect"
"time"
)
// slotObservation 只缓存一个固定时间槽的不可变触发和核验证据。
type slotObservation struct {
occurrence *orbit.ScheduleOccurrence // occurrence 首次读到的快照;不得改写。
verified bool // verified 是否已完成全部持久化核验。
attemptID string // attemptID 唯一成功尝试,用于与本地调用关联。
trigger, queue, execution, completion time.Duration // 各阶段只使用数据库时间。
}
// planObservation 预分配固定槽位,内存上限由配置限制。
type planObservation struct {
index int // index 计划在本次配置中的序号。
start time.Time // start 固定触发基准。
namespace string // namespace 预检认证队列命名空间。
slots []slotObservation // slots 预期触发槽位,不随服务端输入增长。
}
// observations 合并轮询快照,跨计划检查唯一触发和任务身份。
type observations struct {
cfg config // cfg 固定样本配置。
runID string // runID 固定测量身份。
plans map[string]*planObservation // plans 创建时登记的有限计划集合。
occurrences map[string]string // occurrences 触发ID到计划ID,避免跨计划重复。
tasks map[string]*slotObservation // tasks 已观察任务到唯一槽位。
}
func newObservations(cfg config, runID string) *observations {
return &observations{cfg: cfg, runID: runID, plans: map[string]*planObservation{}, occurrences: map[string]string{}, tasks: map[string]*slotObservation{}}
}
func (o *observations) addPlan(id string, index int, start time.Time, namespace string) {
o.plans[id] = &planObservation{index: index, start: start, namespace: namespace, slots: make([]slotObservation, o.cfg.Requests)}
}
func (o *observations) ingest(id string, page orbit.ScheduleOccurrencePage) string {
p := o.plans[id]
if p == nil || page.Items == nil || page.NextCursor != "" || len(page.Items) > len(p.slots) {
return "OCCURRENCE_PAGE_MISMATCH"
}
seen := map[string]bool{}
for _, item := range page.Items {
delta := item.ScheduledAt.Sub(p.start)
if !validID(item.ID) || item.ScheduleID != id || item.ScheduleVersion != 1 || delta < 0 || delta%o.cfg.Interval != 0 || delta/o.cfg.Interval >= time.Duration(len(p.slots)) || item.CreatedAt.Before(item.ScheduledAt) {
return "OCCURRENCE_MISMATCH"
}
if seen[item.ID] {
return "DUPLICATE_OCCURRENCE"
}
seen[item.ID] = true
slot := &p.slots[int(delta/o.cfg.Interval)]
if slot.occurrence != nil {
if !reflect.DeepEqual(*slot.occurrence, item) {
return "OCCURRENCE_CHANGED"
}
continue
}
if _, exists := o.occurrences[item.ID]; exists {
return "DUPLICATE_OCCURRENCE"
}
// 保存已观察证据再判定策略异常;未验证的触发不会充当成功分母。
snapshot := item
slot.occurrence = &snapshot
o.occurrences[item.ID] = id
if item.Decision != "FIRED" || item.CoveredUntil != nil || item.Reason != "SCHEDULED" {
return "OCCURRENCE_DECISION_MISMATCH"
}
if item.TaskID == nil || !validID(*item.TaskID) {
return "OCCURRENCE_TASK_MISMATCH"
}
if _, exists := o.tasks[*item.TaskID]; exists {
return "DUPLICATE_TASK"
}
o.tasks[*item.TaskID] = slot
}
// 此样本总量不超过单页上限,历史不可消失;分页异常不降级继续。
known := 0
for _, s := range p.slots {
if s.occurrence != nil {
known++
}
}
if len(page.Items) != known {
return "OCCURRENCE_DISAPPEARED"
}
return ""
}
func (o *observations) verify(ctx context.Context, api scheduleAPI, id string) (bool, string) {
p := o.plans[id]
complete := true
for i := range p.slots {
slot := &p.slots[i]
if slot.occurrence == nil {
complete = false
continue
}
if slot.verified {
continue
}
if ctx.Err() != nil {
return false, "VERIFY_" + classify(ctx.Err())
}
item := slot.occurrence
if item.TaskID == nil || o.tasks[*item.TaskID] != slot {
return false, "OCCURRENCE_TASK_MISMATCH"
}
call, stop := context.WithTimeout(ctx, o.cfg.Timeout)
task, err := api.GetTask(call, *item.TaskID)
stop()
if err != nil {
return false, "TASK_" + classify(err)
}
if task.ID != *item.TaskID || task.NamespaceID != p.namespace || !matchesTask(task, o.cfg, o.runID, p.index) || !task.CreatedAt.Equal(item.CreatedAt) || !task.AvailableAt.Equal(task.CreatedAt) || !task.DeadlineAt.Equal(task.CreatedAt.Add(time.Duration(o.cfg.RunTimeout.Milliseconds())*time.Millisecond)) {
return false, "TASK_MISMATCH"
}
switch task.State {
case orbit.TaskWaiting, orbit.TaskLeased, orbit.TaskRunning:
complete = false
continue
case orbit.TaskFailed, orbit.TaskCancelled, orbit.TaskNeedsReview:
return false, "TASK_" + string(task.State)
case orbit.TaskSucceeded:
default:
return false, "UNKNOWN_TASK_STATE"
}
output, valid := decodePayload(task.Result)
if !valid || output != (schedulePayload{RunID: o.runID, Schedule: p.index}) || task.CompletedAt == nil || task.EffectUncertain || task.AttemptCount != 1 || task.CancelRequestedAt != nil {
return false, "COMPLETION_MISMATCH"
}
call, stop = context.WithTimeout(ctx, o.cfg.Timeout)
history, err := api.ListTaskAttempts(call, task.ID, orbit.HistoryPageRequest{Limit: 2})
stop()
if err != nil {
return false, "HISTORY_" + classify(err)
}
if len(history.Items) != 1 || history.NextCursor != "" {
return false, "ATTEMPT_COUNT_MISMATCH"
}
a := history.Items[0]
if !validID(a.ID) || a.TaskID != task.ID || a.Epoch != 1 || a.State != orbit.AttemptSucceeded || a.AuthorizedAt == nil || a.EndedAt == nil || a.ErrorCode != "" || a.Retryable || a.ConcurrencyToken != 0 {
return false, "ATTEMPT_MISMATCH"
}
if a.CreatedAt.Before(task.AvailableAt) || a.AuthorizedAt.Before(a.CreatedAt) || a.EndedAt.Before(*a.AuthorizedAt) || !a.EndedAt.Equal(*task.CompletedAt) {
return false, "TIMESTAMP_MISMATCH"
}
call, stop = context.WithTimeout(ctx, o.cfg.Timeout)
source, err := api.GetTaskSource(call, task.ID)
stop()
if err != nil {
return false, "SOURCE_" + classify(err)
}
s := source.Schedule
if source.TaskID != task.ID || source.Kind != "SCHEDULE" || source.Workflow != nil || source.RetryOf != nil || s == nil || s.ScheduleID != id || s.ScheduleVersion != 1 || s.OccurrenceID != item.ID || !s.ScheduledAt.Equal(item.ScheduledAt) || s.Decision != "FIRED" || s.CoveredUntil != nil {
return false, "SOURCE_MISMATCH"
}
slot.verified = true
slot.attemptID = a.ID
slot.trigger = task.CreatedAt.Sub(item.ScheduledAt)
slot.queue = a.AuthorizedAt.Sub(task.CreatedAt)
slot.execution = a.EndedAt.Sub(*a.AuthorizedAt)
slot.completion = task.CompletedAt.Sub(item.ScheduledAt)
}
return complete, ""
}
func matchesTask(t orbit.Task, c config, runID string, index int) bool {
payload, ok := decodePayload(t.Payload)
return ok && payload == (schedulePayload{RunID: runID, Schedule: index}) && validID(t.ID) && t.QueueID == c.QueueID && t.Handler == handlerName(runID) && t.HandlerVersion == "v1" && t.PayloadVersion == "v1" && t.BusinessKey == businessKey(runID, index) && t.ConcurrencyKey == "" && t.Priority == 0 && t.RetryOf == nil && t.StartDeadlineAt == nil && t.Retry == (orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: uint64(c.RunTimeout.Milliseconds())}) && t.RecoverySafety == orbit.RecoveryReconcileOnly
}
func (o *observations) summarize(r *scheduleReport) {
var trigger, queue, execution, completion []time.Duration
for i := range r.Plans {
p := &r.Plans[i]
data := o.plans[p.ID]
if data == nil {
continue
}
for j := range data.slots {
s := &data.slots[j]
if s.occurrence != nil {
p.Occurrences++
}
if s.occurrence != nil && s.occurrence.TaskID != nil && o.tasks[*s.occurrence.TaskID] == s {
p.Fired++
}
if s.verified {
p.VerifiedSuccess++
trigger = append(trigger, s.trigger)
queue = append(queue, s.queue)
execution = append(execution, s.execution)
completion = append(completion, s.completion)
}
}
r.Occurrences += p.Occurrences
r.Fired += p.Fired
r.VerifiedSuccess += p.VerifiedSuccess
}
r.TriggerLag = distributionOf(trigger)
r.QueueWait = distributionOf(queue)
r.Execution = distributionOf(execution)
r.Completion = distributionOf(completion)
}