feat: add scoped task source tracing and SDK examples

This commit is contained in:
2026-09-17 22:40:42 +08:00
parent 7641423f9d
commit 2b8213a4f7
26 changed files with 760 additions and 9 deletions

View File

@@ -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`)

View 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
}

View 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)
}
})
}