Files
SciMesh/coordinator/internal/usecase/job.go
T
Efremenko Arhip 58da6ef139 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.
2026-07-23 15:54:11 +03:00

174 lines
5.1 KiB
Go

package usecase
import (
"context"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// Job operations: the submitter-facing lifecycle of a whole submission.
//
// CreateJob register a job and fan it out into tasks
// GetJobStatus aggregate progress
// ListResults completed manifests, ordered for the stitcher
// StitchJob merge partial results into the final artifact
// --- CreateJob -----------------------------------------------------------
type CreateJob struct {
jobs JobRepository
tasks TaskRepository
tx TxManager
clock Clock
}
func NewCreateJob(jobs JobRepository, tasks TaskRepository, tx TxManager, clock Clock) *CreateJob {
return &CreateJob{jobs: jobs, tasks: tasks, tx: tx, clock: clock}
}
// Execute builds the job and its tasks, then writes them in one transaction.
// The all-or-none guarantee comes from TxManager: a half-created job would
// leave chunks no worker could ever complete.
func (uc *CreateJob) Execute(ctx context.Context, in CreateJobInput) (*domain.Job, error) {
chunks := make([]domain.ChunkSpec, 0, len(in.Chunks))
for _, c := range in.Chunks {
chunks = append(chunks, domain.ChunkSpec(c))
}
job, tasks, err := domain.NewJobWithTasks(in.Workload, in.InputURI, in.Parameters, chunks, uc.clock.Now())
if err != nil {
return nil, err
}
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
if err := uc.jobs.Insert(ctx, job); err != nil {
return err
}
return uc.tasks.InsertBatch(ctx, tasks)
})
if err != nil {
return nil, err
}
return job, nil
}
// --- GetJobStatus --------------------------------------------------------
type GetJobStatus struct {
jobs JobRepository
tasks TaskRepository
}
func NewGetJobStatus(jobs JobRepository, tasks TaskRepository) *GetJobStatus {
return &GetJobStatus{jobs: jobs, tasks: tasks}
}
func (uc *GetJobStatus) Execute(ctx context.Context, jobID uuid.UUID) (domain.JobProgress, error) {
job, err := uc.jobs.Get(ctx, jobID)
if err != nil {
return domain.JobProgress{}, err
}
counts, err := uc.tasks.CountByStatus(ctx, jobID)
if err != nil {
return domain.JobProgress{}, err
}
return progressFrom(*job, counts), nil
}
// --- ListResults ---------------------------------------------------------
type ListResults struct {
tasks TaskRepository
}
func NewListResults(tasks TaskRepository) *ListResults {
return &ListResults{tasks: tasks}
}
// Execute preserves chunk_index order: the stitcher merges these into one
// artifact, and a non-deterministic order would make the final result depend on
// which worker happened to finish first.
func (uc *ListResults) Execute(ctx context.Context, jobID uuid.UUID) ([]domain.ResultManifest, error) {
tasks, err := uc.tasks.ListCompleted(ctx, jobID)
if err != nil {
return nil, err
}
manifests := make([]domain.ResultManifest, 0, len(tasks))
for _, t := range tasks {
if t.ResultArtifactID == nil {
continue // a completed task always references its result; skip defensively
}
manifests = append(manifests, domain.ResultManifest{
TaskID: t.ID,
ChunkIndex: t.ChunkIndex,
ResultArtifactID: *t.ResultArtifactID,
Metrics: t.Metrics,
})
}
return manifests, nil
}
// --- StitchJob -----------------------------------------------------------
// StitchJob merges every chunk's partial result into the job's final artifact.
// For similarity search that means concatenating each worker's local top-k,
// sorting by similarity, and keeping the global top-k — the distributed result
// must match what a single local run would produce.
type StitchJob struct {
results *ListResults
}
func NewStitchJob(results *ListResults) *StitchJob {
return &StitchJob{results: results}
}
// Execute returns the URI of the assembled artifact.
//
// TODO(phase 6): fetch each manifest's CSV, merge, and persist the result.
func (uc *StitchJob) Execute(ctx context.Context, jobID uuid.UUID) (string, error) {
if _, err := uc.results.Execute(ctx, jobID); err != nil {
return "", err
}
return "", ErrNotImplemented
}
// --- shared helpers ------------------------------------------------------
// progressFrom turns a status histogram into the domain's progress view.
func progressFrom(job domain.Job, counts map[domain.TaskStatus]int) domain.JobProgress {
p := domain.JobProgress{
Job: job,
Pending: counts[domain.TaskPending],
Leased: counts[domain.TaskLeased],
Done: counts[domain.TaskCompleted],
Failed: counts[domain.TaskFailed],
}
for _, n := range counts {
p.Total += n
}
return p
}
// syncJobStatus recomputes a job's status from its task counts and persists it.
// Shared by CompleteTask and FailTask so both close a job by the same rule —
// the rule itself lives in domain.JobProgress.DeriveStatus.
func syncJobStatus(ctx context.Context, jobs JobRepository, tasks TaskRepository,
jobID uuid.UUID, now time.Time) error {
counts, err := tasks.CountByStatus(ctx, jobID)
if err != nil {
return err
}
status := progressFrom(domain.Job{}, counts).DeriveStatus()
var completedAt *time.Time
if status == domain.JobCompleted || status == domain.JobFailed {
completedAt = &now
}
return jobs.UpdateStatus(ctx, jobID, status, completedAt)
}