Adds the SciMesh coordinator: a durable task-queue server on PostgreSQL that owns all database access, with workers reaching it over HTTP only. Structured as a modular monolith following Clean Architecture: domain entities and their invariants, no I/O usecase business operations + repository/clock ports transport HTTP handlers, DTOs, auth, error mapping storage PostgreSQL repositories, transactions carried in context infra config, pool, clock, server, lease reaper Dependencies point strictly inward; domain imports nothing from the module. Working: layer wiring, routing, shared-token auth, access logging, request IDs, domain-error to status-code mapping, transactional boundaries, graceful shutdown (HTTP drain -> reaper stop -> pool close), migrations, and a Compose stack starting Postgres -> migrations -> coordinator. The domain is complete and covered by unit tests that need no database: lease ownership, stale attempts, idempotent result replay, retry budgets, and lease expiry. Repository methods are stubs returning ErrNotImplemented (HTTP 501). The SQL for atomic claiming (FOR UPDATE SKIP LOCKED) and for lease expiry is written and ready to wire up. See coordinator/ARCHITECTURE.md for the layer map and a request traced through every layer.
175 lines
5.2 KiB
Go
175 lines
5.2 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.ResultURI == nil || t.ResultSHA256 == nil {
|
|
continue // a completed task always carries both; skip defensively
|
|
}
|
|
manifests = append(manifests, domain.ResultManifest{
|
|
TaskID: t.ID,
|
|
ChunkIndex: t.ChunkIndex,
|
|
ResultURI: *t.ResultURI,
|
|
ResultSHA256: *t.ResultSHA256,
|
|
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)
|
|
}
|