feat(coordinator): complete tasks with an artifact_id, not a URI (CTX-05, part 3)

Task results are now coordinator-owned artifacts end to end.

- domain.Task carries ResultArtifactID instead of ResultURI/ResultSHA256;
  CompleteWith and its idempotency key are keyed on the artifact id.
- CompleteTask verifies the referenced artifact was stored for this exact
  task (rule 10): a worker cannot finish task B with task A's artifact, nor
  name an id that isn't a partial_result. Mismatch → 409.
- POST /tasks/{id}/result takes {result:{artifact_id,...}}; ListResults and
  ResultManifest follow.
- migration 0004 drops result_uri/result_sha256 and requires a completed task
  to reference its result_artifact_id.
- smoke and requests.http exercise upload → complete-by-id → replay → conflict.
This commit is contained in:
Efremenko Arhip
2026-07-23 15:54:11 +03:00
parent 6d45406ee0
commit 58da6ef139
14 changed files with 184 additions and 116 deletions
+31 -34
View File
@@ -28,27 +28,26 @@ const ErrCodeLeaseExpired = "lease_expired"
// Nullable columns are pointers so "no lease" stays distinguishable from
// "lease owned by the empty string" — a plain string cannot express both.
type Task struct {
ID uuid.UUID
JobID uuid.UUID
ChunkIndex int
Workload string
InputURI string
InputSHA256 string
Parameters map[string]any
Status TaskStatus
Attempt int
MaxAttempts int
LeaseOwner *string
LeaseExpiresAt *time.Time
ResultURI *string
ResultSHA256 *string
Metrics map[string]any
ErrorCode *string
ErrorMessage *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
Version int
ID uuid.UUID
JobID uuid.UUID
ChunkIndex int
Workload string
InputURI string
InputSHA256 string
Parameters map[string]any
Status TaskStatus
Attempt int
MaxAttempts int
LeaseOwner *string
LeaseExpiresAt *time.Time
ResultArtifactID *uuid.UUID
Metrics map[string]any
ErrorCode *string
ErrorMessage *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
Version int
}
// NewTask builds a pending task. maxAttempts <= 0 falls back to the default.
@@ -146,17 +145,16 @@ func (t *Task) RenewLease(worker string, attempt int, until time.Time) error {
// retry the same manifest, and that must succeed rather than trip the lease
// check on a task the coordinator already finished. A *different* manifest for
// an already-completed task is a genuine conflict.
func (t *Task) CompleteWith(resultURI, resultSHA256 string, metrics map[string]any,
func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any,
worker string, attempt int, now time.Time) error {
if resultURI == "" || resultSHA256 == "" {
if resultArtifactID == uuid.Nil {
return ErrInvalidInput
}
if t.Status == TaskCompleted {
if t.Attempt == attempt && t.ResultURI != nil && *t.ResultURI == resultURI &&
t.ResultSHA256 != nil && *t.ResultSHA256 == resultSHA256 {
return nil // same attempt, same manifest — replay of a successful call
if t.Attempt == attempt && t.ResultArtifactID != nil && *t.ResultArtifactID == resultArtifactID {
return nil // same attempt, same artifact — replay of a successful call
}
return ErrResultConflict
}
@@ -166,8 +164,7 @@ func (t *Task) CompleteWith(resultURI, resultSHA256 string, metrics map[string]a
}
t.Status = TaskCompleted
t.ResultURI = &resultURI
t.ResultSHA256 = &resultSHA256
t.ResultArtifactID = &resultArtifactID
t.Metrics = metrics
t.CompletedAt = &now
t.LeaseOwner = nil
@@ -234,11 +231,11 @@ type ClaimedTask struct {
LeaseExpiresAt time.Time
}
// ResultManifest is a completed task's output, ordered for the stitcher.
// ResultManifest is a completed task's output, ordered for the stitcher. It
// points at the coordinator-owned result artifact rather than a worker URI.
type ResultManifest struct {
TaskID uuid.UUID
ChunkIndex int
ResultURI string
ResultSHA256 string
Metrics map[string]any
TaskID uuid.UUID
ChunkIndex int
ResultArtifactID uuid.UUID
Metrics map[string]any
}
+12 -10
View File
@@ -9,9 +9,11 @@ import (
)
var (
testNow = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
testLater = testNow.Add(time.Hour)
testWorker = "worker-1"
testNow = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
testLater = testNow.Add(time.Hour)
testWorker = "worker-1"
testResult = uuid.New()
testResultAlt = uuid.New()
)
// leasedTask builds a task already leased to testWorker at the given attempt.
@@ -32,7 +34,7 @@ func leasedTask(attempt, maxAttempts int) *Task {
func TestCompleteWithRecordsResult(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Status != TaskCompleted {
@@ -50,12 +52,12 @@ func TestCompleteWithRecordsResult(t *testing.T) {
// rather than fail on the lease it has already given up.
func TestCompleteWithIsIdempotentForSameManifest(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("first call: %v", err)
}
versionAfterFirst := task.Version
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testLater); err != nil {
if err := task.CompleteWith(testResult, nil, testWorker, 1, testLater); err != nil {
t.Fatalf("replay must be idempotent, got %v", err)
}
if task.Version != versionAfterFirst {
@@ -65,11 +67,11 @@ func TestCompleteWithIsIdempotentForSameManifest(t *testing.T) {
func TestCompleteWithRejectsDifferentManifest(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("first call: %v", err)
}
err := task.CompleteWith("s3://other.csv", "def", nil, testWorker, 1, testLater)
err := task.CompleteWith(testResultAlt, nil, testWorker, 1, testLater)
if !errors.Is(err, ErrResultConflict) {
t.Errorf("err = %v, want ErrResultConflict", err)
}
@@ -78,7 +80,7 @@ func TestCompleteWithRejectsDifferentManifest(t *testing.T) {
func TestCompleteWithRejectsForeignWorker(t *testing.T) {
task := leasedTask(1, 3)
err := task.CompleteWith("s3://r.csv", "abc", nil, "worker-2", 1, testNow)
err := task.CompleteWith(testResult, nil, "worker-2", 1, testNow)
if !errors.Is(err, ErrLeaseConflict) {
t.Errorf("err = %v, want ErrLeaseConflict", err)
}
@@ -87,7 +89,7 @@ func TestCompleteWithRejectsForeignWorker(t *testing.T) {
func TestCompleteWithRejectsStaleAttempt(t *testing.T) {
task := leasedTask(2, 3) // task is on attempt 2
err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow) // worker thinks it is 1
err := task.CompleteWith(testResult, nil, testWorker, 1, testNow) // worker thinks it is 1
if !errors.Is(err, ErrStaleAttempt) {
t.Errorf("err = %v, want ErrStaleAttempt", err)
}