231 lines
6.6 KiB
Go
231 lines
6.6 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/emil28092005/SciMesh/coordinator/internal/chunk"
|
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
|
)
|
|
|
|
// SubmitDataset accepts an uploaded dataset, splits it into shard artifacts, and
|
|
// creates the job with one task per shard — the coordinator-side counterpart of
|
|
// a client submitting pre-chunked URIs.
|
|
type SubmitDataset struct {
|
|
blobs BlobStore
|
|
artifacts ArtifactRepository
|
|
jobs JobRepository
|
|
tasks TaskRepository
|
|
tx TxManager
|
|
clk Clock
|
|
maxAttempts int
|
|
}
|
|
|
|
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
|
|
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int) *SubmitDataset {
|
|
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts}
|
|
}
|
|
|
|
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
|
|
if err := validateUploadedWorkload(in.Workload, in.Parameters); err != nil {
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
if uc.maxAttempts < 1 {
|
|
return SubmitDatasetResult{}, domain.ErrInvalidInput
|
|
}
|
|
now := uc.clk.Now()
|
|
|
|
job, err := domain.NewUploadedJob(in.Workload, in.Parameters, now)
|
|
if err != nil {
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
|
|
// Everything written to blob storage, so a failed transaction can undo it.
|
|
var putKeys []string
|
|
cleanup := func() {
|
|
for _, k := range putKeys {
|
|
_ = uc.blobs.Delete(ctx, k)
|
|
}
|
|
}
|
|
|
|
// 1. Stream the upload into the input artifact; we measure size and sha256.
|
|
input, err := domain.NewArtifact(job.ID, nil, domain.ArtifactInput, in.Filename, in.ContentType, now)
|
|
if err != nil {
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
sum, size, err := uc.blobs.Put(ctx, input.StorageKey, in.Body)
|
|
if err != nil {
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
putKeys = append(putKeys, input.StorageKey)
|
|
input.SetContent(sum, size)
|
|
|
|
// 2. Re-open the stored input and split it into shard artifacts + tasks.
|
|
shards := []*domain.Artifact{}
|
|
tasks := []*domain.Task{}
|
|
rc, err := uc.blobs.Open(ctx, input.StorageKey)
|
|
if err != nil {
|
|
cleanup()
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
splitErr := chunk.SplitChEMBLTSVLimit(rc, in.RowsPerShard, in.MaxRows, func(index int, shard io.Reader) error {
|
|
art, err := domain.NewArtifact(job.ID, nil, domain.ArtifactShard,
|
|
fmt.Sprintf("shard-%d.tsv", index), in.ContentType, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ssum, ssize, err := uc.blobs.Put(ctx, art.StorageKey, shard)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
putKeys = append(putKeys, art.StorageKey)
|
|
art.SetContent(ssum, ssize)
|
|
|
|
task, err := domain.NewShardTask(job.ID, index, in.Workload, art.ID, ssum, in.Parameters, uc.maxAttempts, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
shards = append(shards, art)
|
|
tasks = append(tasks, task)
|
|
return nil
|
|
})
|
|
_ = rc.Close()
|
|
if splitErr != nil {
|
|
cleanup()
|
|
// Dataset shape is caller input, not an internal coordinator failure.
|
|
return SubmitDatasetResult{}, domain.ErrInvalidInput
|
|
}
|
|
|
|
// 3. Persist job + all artifacts + all tasks atomically.
|
|
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
|
if err := uc.jobs.Insert(ctx, job); err != nil {
|
|
return err
|
|
}
|
|
if err := uc.artifacts.Insert(ctx, input); err != nil {
|
|
return err
|
|
}
|
|
for _, a := range shards {
|
|
if err := uc.artifacts.Insert(ctx, a); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return uc.tasks.InsertBatch(ctx, tasks)
|
|
})
|
|
if err != nil {
|
|
cleanup()
|
|
return SubmitDatasetResult{}, err
|
|
}
|
|
|
|
return SubmitDatasetResult{
|
|
JobID: job.ID,
|
|
TaskCount: len(tasks),
|
|
InputArtifactID: input.ID,
|
|
}, nil
|
|
}
|
|
|
|
// validateUploadedWorkload is deliberately narrow until CTX-07/08/10 adds a
|
|
// typed distributed-workload registry. In particular, running similarity-graph
|
|
// independently per TSV shard is scientifically wrong: cross-shard pairs would
|
|
// be absent from the apparent graph.
|
|
func validateUploadedWorkload(workload string, parameters map[string]any) error {
|
|
if workload != "similarity-search" {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
allowed := map[string]struct{}{
|
|
"query_smiles": {}, "top_k": {}, "threshold": {},
|
|
"threshold_direction": {}, "progress_every": {},
|
|
}
|
|
for key := range parameters {
|
|
if _, ok := allowed[key]; !ok {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
}
|
|
query, ok := parameters["query_smiles"].(string)
|
|
if !ok || query == "" || len(query) > 200 {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if value, ok := parameters["top_k"]; ok && !isPositiveJSONInteger(value) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if value, ok := parameters["progress_every"]; ok && !isNonNegativeJSONInteger(value) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if value, ok := parameters["threshold"]; ok && !isUnitIntervalNumber(value) {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
if value, ok := parameters["threshold_direction"]; ok && value != "greater" && value != "less" {
|
|
return domain.ErrInvalidInput
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isPositiveJSONInteger(value any) bool { return isJSONInteger(value, false) }
|
|
func isNonNegativeJSONInteger(value any) bool { return isJSONInteger(value, true) }
|
|
|
|
func isJSONInteger(value any, allowZero bool) bool {
|
|
var n int64
|
|
switch v := value.(type) {
|
|
case int:
|
|
n = int64(v)
|
|
case int64:
|
|
n = v
|
|
case float64:
|
|
if math.Trunc(v) != v || v > math.MaxInt64 || v < math.MinInt64 {
|
|
return false
|
|
}
|
|
n = int64(v)
|
|
default:
|
|
return false
|
|
}
|
|
return n >= 0 && (allowZero || n > 0)
|
|
}
|
|
|
|
func isUnitIntervalNumber(value any) bool {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
|
|
case int:
|
|
return v >= 0 && v <= 1
|
|
case int64:
|
|
return v >= 0 && v <= 1
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// GetTaskInput resolves a task's input shard and opens it for streaming. The
|
|
// caller closes the reader.
|
|
type GetTaskInput struct {
|
|
tasks TaskRepository
|
|
artifacts ArtifactRepository
|
|
blobs BlobStore
|
|
}
|
|
|
|
func NewGetTaskInput(tasks TaskRepository, artifacts ArtifactRepository, blobs BlobStore) *GetTaskInput {
|
|
return &GetTaskInput{tasks: tasks, artifacts: artifacts, blobs: blobs}
|
|
}
|
|
|
|
func (uc *GetTaskInput) Execute(ctx context.Context, taskID uuid.UUID) (*domain.Artifact, io.ReadCloser, error) {
|
|
task, err := uc.tasks.Get(ctx, taskID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if task.InputArtifactID == nil {
|
|
// A URI-based task keeps its input outside the coordinator.
|
|
return nil, nil, domain.ErrArtifactNotFound
|
|
}
|
|
art, err := uc.artifacts.Get(ctx, *task.InputArtifactID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
rc, err := uc.blobs.Open(ctx, art.StorageKey)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return art, rc, nil
|
|
}
|