feat: add scoped task source tracing and SDK examples
This commit is contained in:
@@ -64,7 +64,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||
assertApplicationCounts(t, f, 4, 2, 1)
|
||||
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_queue_business_created, DROP INDEX ix_task_queue_business_state_created, MODIFY COLUMN business_key VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等'`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_queue_business_created, DROP INDEX ix_task_queue_business_state_created, MODIFY COLUMN business_key VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等'`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_retry_not_self, DROP INDEX uq_task_retry_parent, ADD INDEX fk_pulse_tasks_3(namespace_id,retry_of)`)
|
||||
f.exec(`DROP TABLE orbit_application_resources`)
|
||||
f.exec(`DROP TABLE orbit_result_events,orbit_event_streams`)
|
||||
|
||||
90
internal/storage/mysql/task_source.go
Normal file
90
internal/storage/mysql/task_source.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID) (service.TaskSourceView, error) {
|
||||
out := service.TaskSourceView{TaskID: id}
|
||||
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
var queue core.ID
|
||||
var owner sql.NullString
|
||||
var ownerID, gate, retry *core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT queue_id,owner_kind,owner_id,gate_id,retry_of FROM pulse_tasks WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&queue, &owner, &ownerID, &gate, &retry)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.AllowsQueue(queue) {
|
||||
return fault.NotFound
|
||||
}
|
||||
// 来源字段由创建事务固定;当前未实现的门控/所有者不能被误判为普通受理。
|
||||
if gate != nil || owner.Valid != (ownerID != nil) || owner.Valid && retry != nil {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if owner.Valid {
|
||||
if owner.String != "schedule" {
|
||||
return fault.Unavailable
|
||||
}
|
||||
var source service.TaskScheduleSource
|
||||
// 每条关联均显式限定命名空间、计划、版本与任务队列,不依赖数据库外键。
|
||||
err := tx.QueryRowContext(ctx, `SELECT o.schedule_id,o.schedule_version,o.id,o.scheduled_at,o.decision,o.covered_until
|
||||
FROM pulse_schedule_occurrences o
|
||||
JOIN pulse_schedules s ON s.namespace_id=o.namespace_id AND s.id=o.schedule_id AND s.queue_id=?
|
||||
JOIN pulse_schedule_versions v ON v.namespace_id=s.namespace_id AND v.schedule_id=s.id AND v.version=o.schedule_version AND v.version<=s.current_version
|
||||
WHERE o.namespace_id=? AND o.task_id=? AND o.schedule_id=? AND JSON_UNQUOTE(JSON_EXTRACT(v.task_template,'$.queue_id'))=?`, queue, t.NamespaceID, id, *ownerID, string(queue)).Scan(&source.ScheduleID, &source.ScheduleVersion, &source.OccurrenceID, &source.ScheduledAt, &source.Decision, &source.CoveredUntil)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if source.ScheduleVersion == 0 || !sourceTimeValid(source.ScheduledAt) || source.CoveredUntil != nil && !sourceTimeValid(*source.CoveredUntil) || source.Decision == "FIRED" && source.CoveredUntil != nil || source.Decision == "COALESCED" && (source.CoveredUntil == nil || !source.CoveredUntil.After(source.ScheduledAt)) || source.Decision != "FIRED" && source.Decision != "COALESCED" {
|
||||
return fault.Unavailable
|
||||
}
|
||||
out.Kind = "SCHEDULE"
|
||||
out.Schedule = &source
|
||||
return nil
|
||||
}
|
||||
var occurrences int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_schedule_occurrences WHERE namespace_id=? AND task_id=?`, t.NamespaceID, id).Scan(&occurrences); err != nil {
|
||||
return err
|
||||
}
|
||||
if occurrences != 0 {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if retry == nil {
|
||||
out.Kind = "DIRECT"
|
||||
return nil
|
||||
}
|
||||
var parent core.ID
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_tasks WHERE namespace_id=? AND id=? AND id<>? AND queue_id=? AND owner_kind IS NULL AND owner_id IS NULL AND gate_id IS NULL AND state IN ('FAILED','CANCELLED')`, t.NamespaceID, *retry, id, queue).Scan(&parent)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.Unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Kind = "MANUAL_RETRY"
|
||||
out.RetryOf = &parent
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return service.TaskSourceView{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sourceTimeValid(t time.Time) bool {
|
||||
return t.Year() >= 2000 && t.Year() <= 9998 && t.Nanosecond()%1000 == 0
|
||||
}
|
||||
200
internal/storage/mysql/task_source_integration_test.go
Normal file
200
internal/storage/mysql/task_source_integration_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
func TestIntegrationTaskSource(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer stop()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("direct_and_manual_retry_are_read_only_and_scoped", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
grantTaskRecovery(f)
|
||||
original := f.submit(1)
|
||||
source, err := f.tasks.GetTaskSource(ctx, f.token, original.ID)
|
||||
if err != nil || source.TaskID != original.ID || source.Kind != "DIRECT" || source.Schedule != nil || source.RetryOf != nil {
|
||||
t.Fatal(source, err)
|
||||
}
|
||||
cancelled, err := f.tasks.CancelTask(ctx, f.token, original.ID, "cancel", service.CancelInput{ExpectedRevision: original.Revision, Reason: "retry source regression"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, err := f.tasks.RetryTask(ctx, f.token, original.ID, "retry", manualRetryInput(cancelled.Task))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
if err != nil || source.Kind != "MANUAL_RETRY" || source.Schedule != nil || source.RetryOf == nil || *source.RetryOf != original.ID {
|
||||
t.Fatal(source, err)
|
||||
}
|
||||
if !reflect.DeepEqual(f.get(original.ID), cancelled.Task) || !reflect.DeepEqual(f.get(child.Task.ID), child.Task) {
|
||||
t.Fatal("source read changed task")
|
||||
}
|
||||
other := newExecutionFixture(t, ctx, db)
|
||||
_, err = other.tasks.GetTaskSource(ctx, other.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(other.resources.QueueID), f.resources.NamespaceID, f.resources.CredentialID)
|
||||
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY('*')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
// 模拟无外键库的缺失与跨命名空间原任务,不能返回未验证的标识。
|
||||
for _, parent := range []core.ID{executionID(t), other.submit(1).ID} {
|
||||
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, parent, f.resources.NamespaceID, child.Task.ID)
|
||||
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, original.ID, f.resources.NamespaceID, child.Task.ID)
|
||||
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||
expectWorkerCode(t, err, "UNAUTHENTICATED")
|
||||
})
|
||||
t.Run("schedule_source_preserves_published_version_and_permission_boundary", func(t *testing.T) {
|
||||
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||
in := f.definition()
|
||||
plan := f.create(in)
|
||||
f.due(plan, 2*time.Second)
|
||||
f.tick()
|
||||
o := f.occurrences(plan.ID)[0]
|
||||
if o.TaskID == nil {
|
||||
t.Fatal("missing task")
|
||||
}
|
||||
before := f.get(*o.TaskID)
|
||||
source, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||
if err != nil || source.Kind != "SCHEDULE" || source.Schedule == nil || source.RetryOf != nil {
|
||||
t.Fatal(source, err)
|
||||
}
|
||||
link := source.Schedule
|
||||
if link.ScheduleID != plan.ID || link.OccurrenceID != o.ID || link.ScheduleVersion != o.ScheduleVersion || !link.ScheduledAt.Equal(o.ScheduledAt) || link.Decision != o.Decision {
|
||||
t.Fatal("incorrect schedule lineage", link, o)
|
||||
}
|
||||
in.Task.Payload = json.RawMessage(`{"version":2}`)
|
||||
now := f.current(plan.ID)
|
||||
published, err := f.api.Publish(ctx, f.token, plan.ID, "publish", schedule.PublishInput{ExpectedRevision: now.Revision, Reason: "source stays on original version", Definition: in})
|
||||
if err != nil || published.Schedule.CurrentVersion != 2 {
|
||||
t.Fatal(published, err)
|
||||
}
|
||||
after, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||
if err != nil || !reflect.DeepEqual(source, after) || !reflect.DeepEqual(before, f.get(*o.TaskID)) {
|
||||
t.Fatal("latest publication changed source", after, err)
|
||||
}
|
||||
// tasks:read仅授予此任务的来源引用,不因此授予完整计划/模板查询权限。
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY('tasks:read')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, *o.TaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.api.Get(ctx, f.token, plan.ID)
|
||||
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||
server := httptest.NewServer(httpserver.New("", func(context.Context) error { return nil }, f.tasks).Handler)
|
||||
defer server.Close()
|
||||
call := func(path string, status int) []byte {
|
||||
t.Helper()
|
||||
r, _ := http.NewRequestWithContext(ctx, "GET", server.URL+path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+f.resources.Token)
|
||||
resp, err := server.Client().Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != status {
|
||||
t.Fatalf("status=%d want=%d body=%s", resp.StatusCode, status, raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
path := "/v1/tasks/" + string(*o.TaskID) + "/source"
|
||||
raw := call(path, 200)
|
||||
for _, secret := range []string{"task_template", "payload", "lease_secret", "secret_digest", "token"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatal("source leaked unrelated data")
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"?limit=1", "?namespace_id=x", "?unknown=%zz"} {
|
||||
call(path+suffix, 400)
|
||||
}
|
||||
call("/v1/tasks/not-an-id/source", 400)
|
||||
call("/v1/tasks/"+string(executionID(t))+"/source", 404)
|
||||
})
|
||||
t.Run("coalesced_source_retains_exclusive_range", func(t *testing.T) {
|
||||
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||
in := f.definition()
|
||||
in.Policy.Misfire = "COALESCE"
|
||||
plan := f.create(in)
|
||||
f.due(plan, 3*time.Second)
|
||||
f.tick()
|
||||
found := false
|
||||
for _, o := range f.occurrences(plan.ID) {
|
||||
if o.Decision != "COALESCED" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
source, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||
if err != nil || source.Schedule == nil || source.Schedule.CoveredUntil == nil || !source.Schedule.CoveredUntil.Equal(*o.CoveredUntil) || !source.Schedule.ScheduledAt.Equal(o.ScheduledAt) {
|
||||
t.Fatal(source, err)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("coalesced trigger missing")
|
||||
}
|
||||
})
|
||||
t.Run("dangling_mismatched_and_unknown_owners_fail_closed", func(t *testing.T) {
|
||||
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||
plan := f.create(f.definition())
|
||||
f.due(plan, time.Second)
|
||||
f.tick()
|
||||
o := f.occurrences(plan.ID)[0]
|
||||
id := *o.TaskID
|
||||
broken := func() {
|
||||
t.Helper()
|
||||
out, err := f.tasks.GetTaskSource(ctx, f.token, id)
|
||||
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||
if out.TaskID != "" || out.Schedule != nil {
|
||||
t.Fatal("partial invalid source returned")
|
||||
}
|
||||
}
|
||||
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, id)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, plan.ID, f.resources.NamespaceID, id)
|
||||
f.exec(`UPDATE pulse_schedule_occurrences SET schedule_version=999 WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, o.ID)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_schedule_occurrences SET schedule_version=? WHERE namespace_id=? AND id=?`, o.ScheduleVersion, f.resources.NamespaceID, o.ID)
|
||||
f.exec(`UPDATE pulse_schedules SET queue_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, plan.ID)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_schedules SET queue_id=? WHERE namespace_id=? AND id=?`, f.resources.QueueID, f.resources.NamespaceID, plan.ID)
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, id)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind='workflow',owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, id)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_tasks SET owner_kind='schedule',owner_id=?,gate_id=? WHERE namespace_id=? AND id=?`, plan.ID, executionID(t), f.resources.NamespaceID, id)
|
||||
broken()
|
||||
f.exec(`UPDATE pulse_tasks SET gate_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, id)
|
||||
if _, err = f.tasks.GetTaskSource(ctx, f.token, id); err != nil {
|
||||
t.Fatal("restored valid source still refused", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user