314 lines
14 KiB
Go
314 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testID(n int) string { return fmt.Sprintf("%032x", n) }
|
|
func testConfig() config {
|
|
return config{QueueID: testID(1), Requests: 2, Schedules: 1, Workers: 1, Capacity: 2, StartDelay: 5 * time.Second, Interval: time.Second, RunTimeout: time.Minute, Timeout: time.Second, PollInterval: 100 * time.Millisecond}
|
|
}
|
|
func TestDecodePayloadStrict(t *testing.T) {
|
|
want := schedulePayload{RunID: "run", Schedule: 0}
|
|
for _, raw := range []string{`{"run_id":"run","schedule":0}`, ` {"schedule":0,"run_id":"run"} `} {
|
|
if got, ok := decodePayload(json.RawMessage(raw)); !ok || got != want {
|
|
t.Fatalf("valid payload rejected %s", raw)
|
|
}
|
|
}
|
|
for _, raw := range []string{`{}`, `null`, `[]`, `{"run_id":"run"}`, `{"schedule":0}`, `{"run_id":null,"schedule":0}`, `{"run_id":"run","schedule": null }`, `{"run_id":"run","schedule":0.0}`, `{"run_id":"run","schedule":0,"extra":1}`, `{"run_id":"run","Schedule":0}`, `{"run_id":"run","schedule":0,"schedule":0}`, `{"run_id":"run","schedule":0} {}`} {
|
|
if _, ok := decodePayload(json.RawMessage(raw)); ok {
|
|
t.Fatalf("invalid payload accepted %s", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// memoryAPI 仅为观察/收尾提供持久快照替身;未使用方法通过嵌入接口留空。
|
|
type memoryAPI struct {
|
|
scheduleAPI
|
|
task orbit.Task
|
|
history orbit.AttemptPage
|
|
source orbit.TaskSource
|
|
current orbit.Schedule
|
|
reads, pauses int
|
|
keys []string
|
|
pauseError error
|
|
conflictOnce bool
|
|
}
|
|
|
|
func (m *memoryAPI) GetTask(context.Context, string) (orbit.Task, error) {
|
|
m.reads++
|
|
return m.task, nil
|
|
}
|
|
func (m *memoryAPI) ListTaskAttempts(context.Context, string, orbit.HistoryPageRequest) (orbit.AttemptPage, error) {
|
|
return m.history, nil
|
|
}
|
|
func (m *memoryAPI) GetTaskSource(context.Context, string) (orbit.TaskSource, error) {
|
|
return m.source, nil
|
|
}
|
|
func (m *memoryAPI) GetSchedule(context.Context, string) (orbit.Schedule, error) {
|
|
return m.current, nil
|
|
}
|
|
func (m *memoryAPI) PauseSchedule(_ context.Context, _ string, key string, in orbit.ControlScheduleRequest) (orbit.ScheduleResult, error) {
|
|
m.pauses++
|
|
m.keys = append(m.keys, key)
|
|
if m.pauseError != nil {
|
|
return orbit.ScheduleResult{}, m.pauseError
|
|
}
|
|
if m.conflictOnce && m.pauses == 1 {
|
|
m.current.Revision++
|
|
return orbit.ScheduleResult{}, &orbit.APIError{StatusCode: 409, Code: "STATE_CONFLICT"}
|
|
}
|
|
if in.ExpectedRevision != m.current.Revision {
|
|
return orbit.ScheduleResult{}, errors.New("stale revision")
|
|
}
|
|
m.current.Paused = true
|
|
return orbit.ScheduleResult{Schedule: m.current}, nil
|
|
}
|
|
func fixture() (*observations, *memoryAPI, orbit.ScheduleOccurrencePage) {
|
|
c := testConfig()
|
|
c.Requests = 1
|
|
start := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)
|
|
id, taskID := testID(2), testID(3)
|
|
o := newObservations(c, "run")
|
|
o.addPlan(id, 0, start, testID(4))
|
|
item := orbit.ScheduleOccurrence{ID: testID(5), ScheduleID: id, ScheduleVersion: 1, ScheduledAt: start, Decision: "FIRED", TaskID: &taskID, Reason: "SCHEDULED", CreatedAt: start.Add(time.Second)}
|
|
raw, _ := json.Marshal(schedulePayload{RunID: "run", Schedule: 0})
|
|
authorized, end := start.Add(2*time.Second), start.Add(3*time.Second)
|
|
task := orbit.Task{ID: taskID, NamespaceID: testID(4), QueueID: c.QueueID, State: orbit.TaskSucceeded, Handler: handlerName("run"), HandlerVersion: "v1", PayloadVersion: "v1", BusinessKey: businessKey("run", 0), Payload: raw, Result: raw, RecoverySafety: orbit.RecoveryReconcileOnly, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: uint64(c.RunTimeout.Milliseconds())}, AttemptCount: 1, CreatedAt: item.CreatedAt, AvailableAt: item.CreatedAt, DeadlineAt: item.CreatedAt.Add(c.RunTimeout), CompletedAt: &end}
|
|
attempt := orbit.AttemptSummary{ID: testID(6), TaskID: taskID, Epoch: 1, State: orbit.AttemptSucceeded, AuthorizedAt: &authorized, EndedAt: &end, CreatedAt: item.CreatedAt}
|
|
source := orbit.TaskSource{TaskID: taskID, Kind: "SCHEDULE", Schedule: &orbit.TaskScheduleSource{ScheduleID: id, ScheduleVersion: 1, OccurrenceID: item.ID, ScheduledAt: start, Decision: "FIRED"}}
|
|
return o, &memoryAPI{task: task, history: orbit.AttemptPage{Items: []orbit.AttemptSummary{attempt}}, source: source}, orbit.ScheduleOccurrencePage{Items: []orbit.ScheduleOccurrence{item}}
|
|
}
|
|
func TestObserverWaitsAndCountsOnce(t *testing.T) {
|
|
o, api, page := fixture()
|
|
id := page.Items[0].ScheduleID
|
|
api.task.State = orbit.TaskRunning
|
|
if code := o.ingest(id, page); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
if complete, code := o.verify(context.Background(), api, id); complete || code != "" {
|
|
t.Fatalf("running counted as success %t %s", complete, code)
|
|
}
|
|
api.task.State = orbit.TaskSucceeded
|
|
for i := 0; i < 3; i++ {
|
|
if code := o.ingest(id, page); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
if complete, code := o.verify(context.Background(), api, id); !complete || code != "" {
|
|
t.Fatalf("verify %t %s", complete, code)
|
|
}
|
|
}
|
|
if api.reads != 2 {
|
|
t.Fatalf("verified task reread %d", api.reads)
|
|
}
|
|
r := newReport(o.cfg, "run")
|
|
r.Plans = []planReport{{ID: id, Resumed: true, Exhausted: true, PauseConfirmed: true}}
|
|
o.summarize(&r)
|
|
r = r.finish()
|
|
if r.Occurrences != 1 || r.Fired != 1 || r.VerifiedSuccess != 1 || r.Completion.Count != 1 || *r.Completion.Max != 3000 || *r.TriggerLag.Max != 1000 || *r.QueueWait.Max != 1000 || *r.Execution.Max != 1000 || r.Complete {
|
|
t.Fatalf("incorrect report %#v", r)
|
|
}
|
|
log := newInvocationLog(1)
|
|
_, err := log.handler(context.Background(), o.cfg, "run").Handle(context.Background(), orbit.WorkerExecution{Task: api.task, AttemptID: api.history.Items[0].ID, Epoch: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
log.summarize(&r, o)
|
|
r.WorkerStarted = 1
|
|
r = r.finish()
|
|
if !r.Complete {
|
|
t.Fatalf("full evidence not recognized: %#v", r)
|
|
}
|
|
r.Plans[0].PauseConfirmed = false
|
|
if r.finish().Complete {
|
|
t.Fatal("unconfirmed cleanup accepted")
|
|
}
|
|
}
|
|
func TestObserverRejectsMalformedSuccess(t *testing.T) {
|
|
tests := map[string]func(*memoryAPI){
|
|
"wrong queue": func(m *memoryAPI) { m.task.QueueID = testID(90) },
|
|
"wrong namespace": func(m *memoryAPI) { m.task.NamespaceID = testID(90) },
|
|
"missing result field": func(m *memoryAPI) { m.task.Result = json.RawMessage(`{"run_id":"run"}`) },
|
|
"wrong input": func(m *memoryAPI) { m.task.Payload = json.RawMessage(`{"run_id":"else","schedule":0}`) },
|
|
"uncertain": func(m *memoryAPI) { m.task.EffectUncertain = true },
|
|
"retry policy": func(m *memoryAPI) { m.task.Retry.MaxAttempts = 2 },
|
|
"extra attempt": func(m *memoryAPI) { m.history.Items = append(m.history.Items, m.history.Items[0]) },
|
|
"attempt cursor": func(m *memoryAPI) { m.history.NextCursor = "next" },
|
|
"wrong attempt owner": func(m *memoryAPI) { m.history.Items[0].TaskID = testID(90) },
|
|
"missing authorization": func(m *memoryAPI) { m.history.Items[0].AuthorizedAt = nil },
|
|
"completion reversed": func(m *memoryAPI) { v := m.task.CreatedAt.Add(-time.Second); m.history.Items[0].EndedAt = &v },
|
|
"source task": func(m *memoryAPI) { m.source.TaskID = testID(90) },
|
|
"source occurrence": func(m *memoryAPI) { m.source.Schedule.OccurrenceID = testID(90) },
|
|
"source version": func(m *memoryAPI) { m.source.Schedule.ScheduleVersion = 2 },
|
|
"source time": func(m *memoryAPI) { m.source.Schedule.ScheduledAt = m.source.Schedule.ScheduledAt.Add(time.Second) },
|
|
"source decision": func(m *memoryAPI) { m.source.Schedule.Decision = "COALESCED" },
|
|
}
|
|
for name, change := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
o, api, page := fixture()
|
|
id := page.Items[0].ScheduleID
|
|
if code := o.ingest(id, page); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
change(api)
|
|
if ok, code := o.verify(context.Background(), api, id); ok || code == "" {
|
|
t.Fatal("malformed success accepted")
|
|
}
|
|
if o.plans[id].slots[0].verified {
|
|
t.Fatal("invalid proof counted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestOccurrenceUniquenessAndHistory(t *testing.T) {
|
|
for name, change := range map[string]func(*orbit.ScheduleOccurrencePage){
|
|
"wrong parent": func(p *orbit.ScheduleOccurrencePage) { p.Items[0].ScheduleID = testID(90) },
|
|
"wrong version": func(p *orbit.ScheduleOccurrencePage) { p.Items[0].ScheduleVersion = 2 },
|
|
"off slot": func(p *orbit.ScheduleOccurrencePage) {
|
|
p.Items[0].ScheduledAt = p.Items[0].ScheduledAt.Add(time.Millisecond)
|
|
},
|
|
"skipped": func(p *orbit.ScheduleOccurrencePage) { p.Items[0].Decision = "SKIPPED" },
|
|
"cursor": func(p *orbit.ScheduleOccurrencePage) { p.NextCursor = "more" },
|
|
"duplicate": func(p *orbit.ScheduleOccurrencePage) { p.Items = append(p.Items, p.Items[0]) },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
o, _, page := fixture()
|
|
id := page.Items[0].ScheduleID
|
|
change(&page)
|
|
if o.ingest(id, page) == "" {
|
|
t.Fatal("invalid page accepted")
|
|
}
|
|
})
|
|
}
|
|
o, _, page := fixture()
|
|
id := page.Items[0].ScheduleID
|
|
if code := o.ingest(id, page); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
if o.ingest(id, orbit.ScheduleOccurrencePage{Items: []orbit.ScheduleOccurrence{}}) != "OCCURRENCE_DISAPPEARED" {
|
|
t.Fatal("lost history accepted")
|
|
}
|
|
changed := page
|
|
changed.Items = append([]orbit.ScheduleOccurrence(nil), page.Items...)
|
|
changed.Items[0].ID = testID(91)
|
|
if o.ingest(id, changed) != "OCCURRENCE_CHANGED" {
|
|
t.Fatal("replaced slot accepted")
|
|
}
|
|
second := testID(92)
|
|
o.addPlan(second, 1, o.plans[id].start, testID(4))
|
|
changed.Items[0].ScheduleID = second
|
|
if o.ingest(second, changed) != "DUPLICATE_TASK" {
|
|
t.Fatal("task shared by two slots accepted")
|
|
}
|
|
}
|
|
func TestHandlerDetectsDuplicateAndUnconfirmedCalls(t *testing.T) {
|
|
o, api, page := fixture()
|
|
id := page.Items[0].ScheduleID
|
|
if code := o.ingest(id, page); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
log := newInvocationLog(1)
|
|
handler := log.handler(context.Background(), o.cfg, "run")
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = handler.Handle(context.Background(), orbit.WorkerExecution{Task: api.task, AttemptID: testID(6), Epoch: 1})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
r := newReport(o.cfg, "run")
|
|
log.summarize(&r, o)
|
|
if r.HandlerCalls != 4 || r.DuplicateInvocations != 3 || r.UnconfirmedInvocations != 4 {
|
|
t.Fatalf("calls lost: %#v", r)
|
|
}
|
|
if ok, code := o.verify(context.Background(), api, id); !ok || code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
r = newReport(o.cfg, "run")
|
|
log.summarize(&r, o)
|
|
if r.UnconfirmedInvocations != 0 || r.DuplicateInvocations != 3 {
|
|
t.Fatal("duplicate hidden after verification")
|
|
}
|
|
}
|
|
func TestPauseOnlyRekeysExplicitRevisionConflict(t *testing.T) {
|
|
cfg := testConfig()
|
|
current := orbit.Schedule{ScheduleSummary: orbit.ScheduleSummary{ID: testID(2), QueueID: cfg.QueueID, Name: operationKey("run", 0, "create"), Revision: 7}}
|
|
m := &memoryAPI{current: current, conflictOnce: true}
|
|
p := planReport{ID: current.ID}
|
|
if code := pauseSchedule(context.Background(), cfg, m, "run", &p); code != "" {
|
|
t.Fatal(code)
|
|
}
|
|
if m.pauses != 2 || !p.PauseConfirmed || p.PauseRevision != 8 || !reflect.DeepEqual(m.keys, []string{operationKey("run", 0, "pause-0"), operationKey("run", 0, "pause-1")}) {
|
|
t.Fatalf("conflict recovery lost identity %#v %#v", p, m)
|
|
}
|
|
for _, err := range []error{context.DeadlineExceeded, errors.New("transport lost"), &orbit.APIError{StatusCode: 409, Code: "IDEMPOTENCY_CONFLICT"}} {
|
|
m = &memoryAPI{current: current, pauseError: err}
|
|
p = planReport{ID: current.ID}
|
|
if code := pauseSchedule(context.Background(), cfg, m, "run", &p); code == "" || m.pauses != 1 || p.PauseConfirmed || p.PauseKey == "" || p.PauseRevision != 7 {
|
|
t.Fatalf("unknown outcome reissued: %#v %#v", p, m)
|
|
}
|
|
}
|
|
}
|
|
func TestScheduleDefinitionFrozenBoundedAndCompared(t *testing.T) {
|
|
cfg := testConfig()
|
|
start := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)
|
|
def := scheduleDefinition(cfg, "run", 0, start)
|
|
if def.Rule.EndAt == nil || !def.Rule.EndAt.Equal(start.Add(2*time.Second)) || def.Task.Retry.MaxAttempts != 1 || def.Policy.Overlap != "PARALLEL" {
|
|
t.Fatal("schedule is not bounded as configured")
|
|
}
|
|
s := orbit.Schedule{ScheduleSummary: orbit.ScheduleSummary{NamespaceID: testID(4), QueueID: cfg.QueueID, Name: operationKey("run", 0, "create"), Revision: 1, CurrentVersion: 1}, Current: orbit.ScheduleVersion{Version: 1, EffectiveAt: start.Add(-time.Second), Definition: def}}
|
|
s.Current.Definition.Rule.CalendarVersion = "go1.27.1-b2d18a7c8fa8"
|
|
if !matchesSchedule(s, cfg, "run", 0, testID(4), start) {
|
|
t.Fatal("normalized frozen schedule rejected")
|
|
}
|
|
s.Current.Definition.Task.Payload = json.RawMessage(`{"run_id":"run","schedule":1}`)
|
|
if matchesSchedule(s, cfg, "run", 0, testID(4), start) {
|
|
t.Fatal("changed template accepted")
|
|
}
|
|
}
|
|
func TestPartialReportPreservesPlannedDenominator(t *testing.T) {
|
|
c := testConfig()
|
|
c.Schedules = 2
|
|
r := newReport(c, "run")
|
|
r.Occurrences = 3
|
|
r.Fired = 2
|
|
r.VerifiedSuccess = 1
|
|
r = r.finish()
|
|
if r.Expected != 4 || r.Unobserved != 1 || r.Unverified != 1 || r.Complete {
|
|
t.Fatalf("bad denominators %#v", r)
|
|
}
|
|
if d := distributionOf(nil); d.P50 != nil || d.P99 != nil {
|
|
t.Fatal("empty percentile is not null")
|
|
}
|
|
}
|
|
|
|
func TestCalendarSnapshotRemainsFixed(t *testing.T) {
|
|
c := testConfig()
|
|
start := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)
|
|
p := planReport{CalendarVersion: "go1.27.1-b2d18a7c8fa8", EffectiveAt: start}
|
|
s := orbit.Schedule{Current: orbit.ScheduleVersion{Definition: scheduleDefinition(c, "run", 0, start), EffectiveAt: start}}
|
|
s.Current.Definition.Rule.CalendarVersion = p.CalendarVersion
|
|
if !sameVersion(s, p) {
|
|
t.Fatal("unchanged snapshot rejected")
|
|
}
|
|
s.Current.Definition.Rule.CalendarVersion = "replacement"
|
|
if sameVersion(s, p) {
|
|
t.Fatal("changed timezone snapshot accepted")
|
|
}
|
|
s.Current.Definition.Rule.CalendarVersion = p.CalendarVersion
|
|
s.Current.EffectiveAt = start.Add(time.Microsecond)
|
|
if sameVersion(s, p) {
|
|
t.Fatal("changed effective time accepted")
|
|
}
|
|
}
|