feat: add durable relative task delays and outage recovery regression

This commit is contained in:
2026-09-17 23:43:57 +08:00
parent 522fe2ef3e
commit a664ec9370
34 changed files with 811 additions and 28 deletions

View File

@@ -0,0 +1,34 @@
package mysql
import (
"encoding/json"
"testing"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestRelativeDelayPreservesLegacySubmissionDigest(t *testing.T) {
// 升级前已规范化的实际请求形状;新可选字段为零时不能改变原幂等摘要。
legacy := json.RawMessage(`{"queue_id":"11111111111111111111111111111111","handler":"object.prepare","handler_version":"v1","payload_version":"v1","payload":{"n":9007199254740993},"task_timeout_ms":86400000,"recovery_safety":"RECONCILE_ONLY","retry":{"max_attempts":1,"initial_delay_ms":0,"max_delay_ms":0,"attempt_timeout_ms":30000}}`)
var in service.SubmitInput
if err := json.Unmarshal(legacy, &in); err != nil {
t.Fatal(err)
}
in, err := service.NormalizeSubmit(in)
if err != nil {
t.Fatal(err)
}
want, err := requestDigest(legacy)
if err != nil {
t.Fatal(err)
}
got, err := requestDigest(in)
if err != nil || got != want {
t.Fatal("zero delay changed a legacy request identity", err)
}
in.DelayMS = 1000
changed, err := requestDigest(in)
if err != nil || changed == want {
t.Fatal("nonzero delay missing from request identity", err)
}
}

View File

@@ -393,4 +393,66 @@ func TestIntegrationTaskAPI(t *testing.T) {
t.Fatal(err)
}
})
t.Run("relative_delay_uses_database_acceptance_and_original_identity", func(t *testing.T) {
delayed := input
delayed.DelayMS = 5000
key := "delayed-submit"
first := must("POST", "/v1/tasks", a.Token, key, marshal(delayed), 201)
var v service.SubmitResult
if err := json.Unmarshal(first.data, &v); err != nil || !v.Task.AvailableAt.Equal(v.Task.CreatedAt.Add(5*time.Second)) {
t.Fatal("delay not anchored to persisted database acceptance", err)
}
const n = 8
delayedBody := marshal(delayed)
results := make(chan apiReply, n)
errs := make(chan error, n)
for range n {
go func() {
out, err := call("POST", "/v1/tasks", a.Token, key, delayedBody)
results <- out
errs <- err
}()
}
for range n {
out, err := <-results, <-errs
var duplicate service.SubmitResult
if err != nil || out.status != 200 || json.Unmarshal(out.data, &duplicate) != nil || !duplicate.Deduplicated || duplicate.Task.ID != v.Task.ID || !duplicate.Task.AvailableAt.Equal(v.Task.AvailableAt) || !duplicate.Task.DeadlineAt.Equal(v.Task.DeadlineAt) {
t.Fatal("repeated delay reset execution time or identity", err)
}
}
changed := delayed
changed.DelayMS++
must("POST", "/v1/tasks", a.Token, key, marshal(changed), 409)
changed = delayed
changed.DelayMS = 0
changed.AvailableAt = &v.Task.AvailableAt
must("POST", "/v1/tasks", a.Token, key, marshal(changed), 409)
changed.DelayMS = delayed.DelayMS
must("POST", "/v1/tasks", a.Token, "both-timing-modes", marshal(changed), 400)
changed = delayed
tooEarly := v.Task.CreatedAt.Add(time.Second)
changed.StartDeadlineAt = &tooEarly
must("POST", "/v1/tasks", a.Token, "delay-after-start-deadline", marshal(changed), 400)
changed = delayed
changed.DelayMS = 86400000
must("POST", "/v1/tasks", a.Token, "delay-at-total-deadline", marshal(changed), 400)
if count("pulse_tasks", "namespace_id=? AND id=?", a.NamespaceID, v.Task.ID) != 1 || count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, v.Task.ID) != 1 {
t.Fatal("delayed duplicate emitted another task or event")
}
// 省略与显式零保持原有即刻/绝对时间请求语义,不破坏升级前的摘要。
zero := bytes.Clone(bytes.TrimSuffix(raw, []byte("}")))
zero = append(zero, []byte(`,"delay_ms":0}`)...)
must("POST", "/v1/tasks", a.Token, "stable-submit", zero, 200)
batch := service.BatchSubmitInput{Items: []service.BatchSubmitItem{{IdempotencyKey: key, Task: delayed}, {IdempotencyKey: "delay-batch-new", Task: delayed}}}
batchReply := must("POST", "/v1/tasks:batch", a.Token, "", marshal(batch), 200)
var batchOut service.BatchSubmitResult
if json.Unmarshal(batchReply.data, &batchOut) != nil || len(batchOut.Items) != 2 || batchOut.Items[0].Receipt == nil || batchOut.Items[1].Receipt == nil || !batchOut.Items[0].Receipt.Deduplicated || batchOut.Items[0].Receipt.TaskID != v.Task.ID {
t.Fatal("batch did not reuse relative-delay identity")
}
fresh := must("GET", "/v1/tasks/"+string(batchOut.Items[1].Receipt.TaskID), a.Token, "", nil, 200)
var freshTask service.TaskView
if json.Unmarshal(fresh.data, &freshTask) != nil || !freshTask.AvailableAt.Equal(freshTask.CreatedAt.Add(5*time.Second)) {
t.Fatal("batch delay did not use its own acceptance time")
}
})
}

View File

@@ -221,7 +221,8 @@ func insertAcceptedTask(ctx context.Context, tx *sql.Tx, ns, taskID core.ID, in
if err != nil {
return service.TaskView{}, err
}
available := now
// 相对时长保留在请求摘要中,只在首次受理时按数据库时钟转换;重报先返回原回执。
available := now.Add(time.Duration(in.DelayMS) * time.Millisecond)
if in.AvailableAt != nil {
available = *in.AvailableAt
}

View File

@@ -18,6 +18,13 @@ func TestBodyAndErrorBoundary(t *testing.T) {
{`{"retry":{"max_attempts":1},"Retry":{"max_attempts":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{}`, "text/plain", "UNSUPPORTED_MEDIA_TYPE"},
{`{"payload":{"arbitrary_key":9007199254740993}}`, "application/json", ""},
{`{"delay_ms":1000}`, "application/json", ""},
{`{"delay_ms":null}`, "application/json", "INVALID_ARGUMENT"},
{`{"delay_ms":-1}`, "application/json", "INVALID_ARGUMENT"},
{`{"delay_ms":1.5}`, "application/json", "INVALID_ARGUMENT"},
{`{"delay_ms":"1000"}`, "application/json", "INVALID_ARGUMENT"},
{`{"delay_ms":18446744073709551616}`, "application/json", "INVALID_ARGUMENT"},
{`{"delay_ms":1,"delay_ms":2}`, "application/json", "INVALID_ARGUMENT"},
} {
r := httptest.NewRequest(http.MethodPost, "/v1/tasks", strings.NewReader(test.body))
r.Header.Set("Content-Type", test.content)