feat: add durable relative task delays and outage recovery regression
This commit is contained in:
34
internal/storage/mysql/task_delay_test.go
Normal file
34
internal/storage/mysql/task_delay_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user